mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 08:50:22 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-436
# Conflicts: # esphome/config_validation.py
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.build_helpers.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 EsphomeError 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,246 @@
|
||||
"""Tests for the ninja build-tool helper script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_gen import build_tool
|
||||
|
||||
|
||||
def test_ar_removes_stale_archive(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "lib.a"
|
||||
archive.write_text("stale")
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert not archive.exists()
|
||||
# The rspfile is expanded by the shim (GNU ar would escape backslashes)
|
||||
assert mock_run.call_args[0][0] == ["ar-bin", "rcs", str(archive), "a.o"]
|
||||
|
||||
|
||||
def test_copy(tmp_path: Path) -> None:
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(dst)]
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert dst.read_text() == "data"
|
||||
|
||||
|
||||
def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch.object(build_tool.sys, "argv", ["build_tool", "bogus"]):
|
||||
assert build_tool.main() == 1
|
||||
assert "unknown build_tool mode" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_runs_as_script(tmp_path: Path) -> None:
|
||||
"""The ninja rules invoke the file as a plain script."""
|
||||
|
||||
src = tmp_path / "a.bin"
|
||||
src.write_text("x")
|
||||
dst = tmp_path / "b.bin"
|
||||
result = subprocess.run(
|
||||
[sys.executable, build_tool.__file__, "copy", str(src), str(dst)],
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert dst.read_text() == "x"
|
||||
|
||||
|
||||
def test_ar_expands_rspfile_without_escaping(tmp_path) -> None:
|
||||
"""Backslash paths survive: the shim expands the rspfile itself instead
|
||||
of letting GNU ar treat backslashes as escapes."""
|
||||
rsp = tmp_path / "objs.rsp"
|
||||
rsp.write_text("obj/a.o\nsub\\b.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(tmp_path / "lib.a"), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"ar-bin",
|
||||
"rcs",
|
||||
str(tmp_path / "lib.a"),
|
||||
"obj/a.o",
|
||||
"sub\\b.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_unquotes_ninja_escaped_paths(tmp_path: Path) -> None:
|
||||
"""The shim strips a simple surrounding quote, since ninja shell-
|
||||
quotes special rsp paths, so ar sees the real filename."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("'obj/a b.o'\nobj/c.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
),
|
||||
patch.object(build_tool.subprocess, "run") as mock_run,
|
||||
):
|
||||
mock_run.return_value.returncode = 0
|
||||
rc = build_tool.main()
|
||||
assert rc == 0
|
||||
assert mock_run.call_args.args[0] == [
|
||||
"/usr/bin/ar",
|
||||
"rcs",
|
||||
"lib.a",
|
||||
"obj/a b.o",
|
||||
"obj/c.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_empty_object_list_fails(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A lost object list is an error here, not undefined symbols at link."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("\n\n")
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
):
|
||||
rc = build_tool.main()
|
||||
assert rc == 1
|
||||
assert "no objects listed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_ar_batches_long_object_lists(tmp_path: Path) -> None:
|
||||
"""The expanded argv must stay under the Windows 32767-char limit: a
|
||||
long object list creates with rcs, then appends with qs."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
objects = [f"dir/{'x' * 120}_{i}.o" for i in range(400)]
|
||||
rsp.write_text("\n".join(objects) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert len(calls) > 1
|
||||
assert calls[0][1] == "rcs"
|
||||
assert all(c[1] == "qs" for c in calls[1:])
|
||||
assert [o for c in calls for o in c[3:]] == objects
|
||||
assert all(sum(len(a) + 1 for a in c) < 32000 for c in calls)
|
||||
|
||||
|
||||
def test_ar_batch_failure_stops(tmp_path: Path) -> None:
|
||||
"""A failing batch propagates its exit code without running the rest."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("\n".join(f"{'y' * 200}_{i}.o" for i in range(300)) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
MagicMock(returncode=3),
|
||||
)[1],
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 3
|
||||
assert mock_run.call_count == 1
|
||||
# The failed batch must not leave a truncated archive behind
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_ar_exception_leaves_no_partial_archive(tmp_path: Path) -> None:
|
||||
"""A missing ar binary mid-loop must not leave a truncated archive from
|
||||
earlier successful batches."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
(_ for _ in ()).throw(FileNotFoundError("no ar")),
|
||||
),
|
||||
),
|
||||
pytest.raises(FileNotFoundError),
|
||||
):
|
||||
build_tool.main()
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A mis-specified ninja rule passing extra operands errors instead of
|
||||
silently dropping them."""
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", "a", "b", "extra"]
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert "expected 2 arguments, got 3" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_copy_same_file_keeps_the_input(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A same-file copy (dst IS src) must not unlink the input, and fails
|
||||
with a message and exit code like the other shim paths."""
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_bytes(b"image")
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(src)]
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert src.read_bytes() == b"image"
|
||||
assert "failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
"""A failed copy unlinks the destination; a partial firmware image must
|
||||
never be left on disk."""
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
dst.write_text("stale")
|
||||
with (
|
||||
patch.object(build_tool.shutil, "copyfile", side_effect=OSError("disk full")),
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "copy", str(tmp_path / "src.bin"), str(dst)],
|
||||
),
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -11,10 +12,12 @@ import pytest
|
||||
from esphome.components.esp32 import (
|
||||
KEY_COMPONENTS,
|
||||
KEY_ESP32,
|
||||
KEY_EXCLUDE_COMPONENTS,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
KEY_REPO,
|
||||
register_exclude_components_cmake_arg,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE
|
||||
@@ -28,25 +31,42 @@ def _reset_core(tmp_path: Path) -> None:
|
||||
CORE.data.setdefault(KEY_CORE, {})
|
||||
CORE.data[KEY_ESP32] = {
|
||||
KEY_COMPONENTS: {},
|
||||
KEY_EXCLUDE_COMPONENTS: set(),
|
||||
KEY_IDF_VERSION: cv.Version(5, 5, 4),
|
||||
}
|
||||
|
||||
|
||||
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
|
||||
def _write_project_description(
|
||||
tmp_path: Path, components: dict[str, str], idf_path: str = "/idf"
|
||||
) -> None:
|
||||
"""Stub a project_description.json with the given component_name -> dir map."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir(exist_ok=True)
|
||||
(build_dir / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"idf_path": idf_path,
|
||||
"build_component_info": {
|
||||
name: {"dir": dir_} for name, dir_ in components.items()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render(minimal: bool = False, builtin_components: list[str] | None = None) -> str:
|
||||
"""Render the top-level CMakeLists with the standard variant/name patches."""
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
return get_project_cmakelists(
|
||||
minimal=minimal, builtin_components=builtin_components
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_build_path() -> None:
|
||||
"""No build_path set yet: must not raise on Path(None)."""
|
||||
CORE.build_path = None
|
||||
@@ -63,8 +83,11 @@ def test_get_available_components_returns_none_without_project_description(
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
|
||||
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
|
||||
def test_get_available_components_keeps_only_idf_tree_components(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Only components under idf_path/components are built-ins: src, managed,
|
||||
converted PIO libs and Arduino component_stubs are all left out."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -72,6 +95,7 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
"cbor": f"{tmp_path}/component_stubs/cbor",
|
||||
"freertos": "/idf/components/freertos",
|
||||
},
|
||||
)
|
||||
@@ -80,6 +104,75 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
|
||||
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
|
||||
|
||||
|
||||
def test_codegen_and_configure_writes_render_the_same_cmakelists(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""write_project() at codegen time (no list) and the configure-time write
|
||||
(discovered list) must agree, or ninja re-runs cmake on every build."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"lwip": "/idf/components/lwip",
|
||||
"cbor": f"{tmp_path}/component_stubs/cbor",
|
||||
},
|
||||
)
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert _render() == _render(builtin_components=get_available_components())
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS cbor" not in _render()
|
||||
|
||||
|
||||
def test_get_available_components_warns_when_nothing_is_under_idf_path(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_project_description(tmp_path, {"cbor": f"{tmp_path}/component_stubs/cbor"})
|
||||
from esphome.build_gen.espidf import (
|
||||
get_available_components,
|
||||
has_discovered_components,
|
||||
)
|
||||
|
||||
assert get_available_components() == []
|
||||
assert "No ESP-IDF components found under" in caplog.text
|
||||
# An empty discovery must not count as configured, or it would be latched in.
|
||||
assert not has_discovered_components()
|
||||
|
||||
|
||||
def test_get_available_components_ignores_corrupt_or_unexpected_file(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
from esphome.build_gen.espidf import (
|
||||
get_available_components,
|
||||
has_discovered_components,
|
||||
)
|
||||
|
||||
(build_dir / "project_description.json").write_text("{not json")
|
||||
assert get_available_components() is None
|
||||
assert not has_discovered_components()
|
||||
(build_dir / "project_description.json").write_text('{"build_component_info": {}}')
|
||||
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.espidf"):
|
||||
assert get_available_components() is None
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
|
||||
def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
|
||||
_write_project_description(tmp_path, {"lwip": "/idf/components/lwip"})
|
||||
from esphome.build_gen.espidf import has_discovered_components
|
||||
|
||||
assert has_discovered_components()
|
||||
|
||||
|
||||
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
|
||||
"""A cached list replaces project_description.json and is still filtered
|
||||
by EXCLUDE_COMPONENTS."""
|
||||
with patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}):
|
||||
content = _render(builtin_components=["lwip", "fatfs", "esp_timer"])
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_timer APPEND" in content
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS lwip APPEND" in content
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS fatfs APPEND" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -88,13 +181,7 @@ def test_get_project_cmakelists_minimal_omits_builtin_components_property(
|
||||
first write before the discovery pass refreshes it)."""
|
||||
_write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"})
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=True)
|
||||
content = _render(minimal=True)
|
||||
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content
|
||||
|
||||
@@ -115,13 +202,7 @@ def test_get_project_cmakelists_full_emits_builtin_components_property(
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=False)
|
||||
content = _render()
|
||||
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)"
|
||||
@@ -136,6 +217,118 @@ def test_get_project_cmakelists_full_emits_builtin_components_property(
|
||||
assert "JPEGDEC APPEND" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_emits_cmake_args() -> None:
|
||||
"""Args registered via CORE.add_cmake_arg() are emitted as set() lines,
|
||||
on minimal writes too."""
|
||||
CORE.add_cmake_arg("EXECUTABLE_COMPONENT_NAME", "src")
|
||||
|
||||
content = _render(minimal=True)
|
||||
|
||||
assert 'set(EXECUTABLE_COMPONENT_NAME "src")' in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_escapes_backslashes_in_cmake_args() -> None:
|
||||
"""Backslashes (the only character escaping applies to; the rest are
|
||||
rejected at registration) are doubled so CMake reads the value back
|
||||
verbatim."""
|
||||
CORE.add_cmake_arg("MY_PATH", r"C:\esp\idf")
|
||||
|
||||
content = _render(minimal=True)
|
||||
|
||||
assert r'set(MY_PATH "C:\\esp\\idf")' in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None:
|
||||
"""Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are
|
||||
dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale
|
||||
project_description.json still lists them (requiring an excluded
|
||||
component would pull it back into the build)."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"freertos": "/idf/components/freertos",
|
||||
"unity": "/idf/components/unity",
|
||||
},
|
||||
)
|
||||
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"}
|
||||
register_exclude_components_cmake_arg()
|
||||
|
||||
content = _render()
|
||||
|
||||
assert 'set(EXCLUDE_COMPONENTS "esp_lcd;unity")' in content
|
||||
# Must be set before project() so project.cmake sees it.
|
||||
assert content.index("set(EXCLUDE_COMPONENTS") < content.index("project(test)")
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)"
|
||||
in content
|
||||
)
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS unity" not in content
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_minimal_emits_exclude_components() -> None:
|
||||
"""The discovery (minimal) write also excludes components so they never
|
||||
register in project_description.json."""
|
||||
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"}
|
||||
register_exclude_components_cmake_arg()
|
||||
|
||||
content = _render(minimal=True)
|
||||
|
||||
assert 'set(EXCLUDE_COMPONENTS "unity")' in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None:
|
||||
"""No EXCLUDE_COMPONENTS line at all when nothing is excluded."""
|
||||
register_exclude_components_cmake_arg()
|
||||
|
||||
content = _render()
|
||||
|
||||
assert "EXCLUDE_COMPONENTS" not in content
|
||||
|
||||
|
||||
def test_include_builtin_idf_component_removes_exclusion() -> None:
|
||||
"""include_builtin_idf_component() drops a name from the exclusion set so
|
||||
a component a config actually uses is not passed to EXCLUDE_COMPONENTS."""
|
||||
from esphome.components.esp32 import (
|
||||
exclude_builtin_idf_component,
|
||||
get_excluded_builtin_components,
|
||||
include_builtin_idf_component,
|
||||
)
|
||||
|
||||
exclude_builtin_idf_component("esp_eth")
|
||||
exclude_builtin_idf_component("unity")
|
||||
include_builtin_idf_component("esp_eth")
|
||||
|
||||
assert get_excluded_builtin_components() == ["unity"]
|
||||
|
||||
register_exclude_components_cmake_arg()
|
||||
content = _render()
|
||||
|
||||
assert 'set(EXCLUDE_COMPONENTS "unity")' in content
|
||||
assert "esp_eth" not in content
|
||||
|
||||
|
||||
def test_write_project_writes_exclude_components_stamp(tmp_path: Path) -> None:
|
||||
"""write_project() snapshots the exclusion set; the toolchain watches the
|
||||
stamp to trigger a discovery reconfigure when the set changes (excluded
|
||||
components never register in project_description.json)."""
|
||||
CORE.build_flags = set()
|
||||
CORE.build_path = tmp_path
|
||||
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"}
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import write_project
|
||||
|
||||
write_project()
|
||||
|
||||
stamp = tmp_path / "exclude_components.esphomeinternal"
|
||||
assert stamp.read_text() == "esp_lcd;unity"
|
||||
|
||||
|
||||
def test_get_component_cmakelists_no_link_flags() -> None:
|
||||
"""With no -Wl, flags the target_link_options block is emitted with an empty body."""
|
||||
CORE.build_flags = set()
|
||||
@@ -184,6 +377,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:
|
||||
|
||||
@@ -169,6 +169,7 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(CORE, "platformio_libraries", {})
|
||||
monkeypatch.setattr(CORE, "build_flags", set())
|
||||
monkeypatch.setattr(CORE, "build_unflags", set())
|
||||
monkeypatch.setattr(CORE, "cmake_args", {})
|
||||
|
||||
|
||||
def test_get_ini_content_pins_cpp_standard(
|
||||
@@ -202,6 +203,49 @@ def test_get_ini_content_no_cpp_standard(
|
||||
assert "-std=" not in content
|
||||
|
||||
|
||||
def test_get_ini_content_emits_cmake_args(
|
||||
clean_core: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Registered args are space-joined into one option, sorted by name."""
|
||||
monkeypatch.setattr(
|
||||
CORE,
|
||||
"cmake_args",
|
||||
{"EXECUTABLE_COMPONENT_NAME": "src", "EXCLUDE_COMPONENTS": "unity"},
|
||||
)
|
||||
|
||||
content = platformio.get_ini_content()
|
||||
|
||||
assert (
|
||||
"board_build.cmake_extra_args = "
|
||||
"-DEXCLUDE_COMPONENTS=unity -DEXECUTABLE_COMPONENT_NAME=src" in content
|
||||
)
|
||||
|
||||
|
||||
def test_get_ini_content_no_cmake_option_when_no_args(clean_core: None) -> None:
|
||||
"""No board_build.cmake_extra_args line at all when nothing registered
|
||||
(ESP8266/RP2040/LibreTiny builds must not get a blank option)."""
|
||||
content = platformio.get_ini_content()
|
||||
|
||||
assert "board_build.cmake_extra_args" not in content
|
||||
|
||||
|
||||
def test_get_ini_content_overwrites_list_valued_user_cmake_option(
|
||||
clean_core: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A user-supplied board_build.cmake_extra_args may be a list; the
|
||||
registered args must replace it without tripping add_platformio_option's
|
||||
list-append assert."""
|
||||
monkeypatch.setattr(
|
||||
CORE, "platformio_options", {"board_build.cmake_extra_args": ["-DFOO=1"]}
|
||||
)
|
||||
monkeypatch.setattr(CORE, "cmake_args", {"EXECUTABLE_COMPONENT_NAME": "src"})
|
||||
|
||||
content = platformio.get_ini_content()
|
||||
|
||||
assert "board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src" in content
|
||||
assert "-DFOO=1" not in content
|
||||
|
||||
|
||||
def test_write_cxx_flags_script_emits_registered_flags(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for the shared ccache policy in esphome.build_helpers.ccache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ccache
|
||||
|
||||
|
||||
def test_resolve_opt_out() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary" not in caplog.text
|
||||
|
||||
|
||||
def test_resolve_probe_failure() -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_explicit_skips_probe_and_warns_missing(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", side_effect=AssertionError),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary is on PATH" in caplog.text
|
||||
|
||||
|
||||
def test_probe_spawns_with_close_fds_false() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ccache._ccache_runs("/usr/bin/ccache") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_defaults_env(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")),
|
||||
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True),
|
||||
):
|
||||
env = ccache.ccache_defaults_env(tmp_path / "cache")
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "cache")
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert "CCACHE_NOHASHDIR" not in env # user value respected
|
||||
|
||||
|
||||
def test_defaults_env_requires_build_path() -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=None)),
|
||||
pytest.raises(ValueError, match="build_path"),
|
||||
):
|
||||
ccache.ccache_defaults_env(Path("/x"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["no", "off", "false", "0"])
|
||||
def test_resolve_opt_out_synonyms(value: str) -> None:
|
||||
"""Every recognized falsy spelling disables ccache."""
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_unrecognized_value_warns_and_probes(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not
|
||||
silently enable ccache or skip the runnability probe."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe,
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
mock_probe.assert_called_once()
|
||||
assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("1", True),
|
||||
("enable", True),
|
||||
("ON", True),
|
||||
("0", False),
|
||||
("disable", False),
|
||||
("Off", False),
|
||||
("maybe", None),
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
("", False),
|
||||
(" ", False),
|
||||
],
|
||||
)
|
||||
def test_parse_enable_env_spelling_tables(
|
||||
monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None
|
||||
) -> None:
|
||||
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Tests for esphome.build_helpers.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import idedata
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "launcher"),
|
||||
[
|
||||
("", None),
|
||||
# A command that is only the launcher strips to nothing
|
||||
("/usr/bin/ccache", "/usr/bin/ccache"),
|
||||
],
|
||||
)
|
||||
def test_parse_entry_empty_command_raises(command: str, launcher: str | None) -> None:
|
||||
"""A blank (or launcher-only) command fails with a named ValueError,
|
||||
not an IndexError."""
|
||||
entry = {"directory": "/b", "file": "/b/src/x.cpp", "command": command}
|
||||
with pytest.raises(ValueError, match="empty compile command"):
|
||||
idedata.parse_entry(entry, launcher)
|
||||
|
||||
|
||||
def test_idedata_from_build_empty_includes_raises(tmp_path: Path) -> None:
|
||||
"""A compile DB with no ESPHome TU is never usable idedata and must
|
||||
not be cached (call sites downgrade the raise to a build warning)."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/other/lib.cpp",
|
||||
"/tools/g++ -c other/lib.cpp -o lib.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
pytest.raises(EsphomeError, match="No ESPHome translation unit found"),
|
||||
):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_rsp_commands_never_dedupe(tmp_path: Path) -> None:
|
||||
"""Per-object response files strip to one shape while holding different
|
||||
include sets; @-commands must tokenize per TU."""
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.cpp.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_idedata_from_build_dedupes_identical_command_shapes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Translation units sharing one ninja rule (same command modulo
|
||||
file/output) carry
|
||||
identical includes, so only one per shape is tokenized; a differing
|
||||
shape still contributes its includes."""
|
||||
|
||||
def _tu(name: str, inc: str) -> dict:
|
||||
# ninja's compdb embeds the file and output strings verbatim
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
return _entry(
|
||||
f"{ABS}build", file, f"/tools/g++ -I{ABS}inc/{inc} -c {file} -o {name}.o"
|
||||
) | {"output": f"{name}.o"}
|
||||
|
||||
entries = [_tu(name, "shared") for name in ("application", "component", "helpers")]
|
||||
entries.append(_tu("extra", "extra"))
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(idedata, "parse_entry", wraps=idedata.parse_entry) as spy,
|
||||
):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
includes = set(data["includes"]["build"])
|
||||
assert f"{ABS}inc/shared".replace("\\", "/") in {
|
||||
i.replace("\\", "/") for i in includes
|
||||
}
|
||||
assert any("inc/extra" in i for i in includes)
|
||||
# Representative + one distinct shape; the two same-shape duplicates
|
||||
# are never tokenized
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
|
||||
|
||||
def test_parse_entry_strips_launcher_prefix() -> None:
|
||||
"""A launcher-wrapped compile names the compiler second; the exact
|
||||
configured launcher is stripped, not anything ccache-shaped."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
|
||||
"-c app.cpp -o app.cpp.o",
|
||||
)
|
||||
cxx_path, defines, _, _ = idedata.parse_entry(
|
||||
entry, launcher="/opt/homebrew/bin/ccache"
|
||||
)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert defines == ["USE_ESP8266"]
|
||||
|
||||
|
||||
def test_parse_entry_recovers_from_unconfigured_launcher(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A stale compile DB built with a launcher this run no longer configures
|
||||
still yields the real compiler (the next token), not the launcher."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o",
|
||||
)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
cxx_path, _, _, _ = idedata.parse_entry(entry)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert "Stripping unconfigured launcher" in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_rejects_launcher_without_program() -> None:
|
||||
"""A launcher followed only by flags is rejected in the parser itself,
|
||||
so no caller can record ccache as the compiler."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache -c a.cpp -o a.o",
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.parse_entry(entry)
|
||||
|
||||
|
||||
def _write_compile_commands(tmp_path: Path) -> Path:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
return compile_commands
|
||||
|
||||
|
||||
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache" / "test.json"
|
||||
with patch.object(
|
||||
idedata, "get_toolchain_includes", return_value=["/toolchain/include"]
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["cc_path"] == "/tools/gcc"
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
assert json.loads(cache.read_text()) == data
|
||||
|
||||
# A fresh cache is served without re-parsing the compile DB
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
== data
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ("not json", json.dumps({"no_cc_path": True})):
|
||||
cache.write_text(bad)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert "cc_path" in data
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_when_compile_db_newer(tmp_path: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
cache.write_text(json.dumps({"cc_path": "stale"}))
|
||||
os.utime(compile_commands, (cache.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cc_path"] != "stale"
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not an object is regenerated, never handed out.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring.
|
||||
"""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ('"cc_path is a string"', "[]", "42"):
|
||||
cache.write_text(bad)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert isinstance(data, dict)
|
||||
assert "cc_path" in data
|
||||
|
||||
|
||||
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
||||
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
|
||||
assert not idedata._is_launcher(token)
|
||||
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
|
||||
assert idedata._is_launcher(token)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_corrupted_cache_is_logged(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A truncated cache is diagnosable, not a silent slow-build cause."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text('{"cc_path": trunc')
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_discards_unreadable_cache_file(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An OSError on the cache read (permissions, I/O) regenerates like a
|
||||
parse failure instead of aborting the consumer."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text("{}")
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
real_read_text = Path.read_text
|
||||
|
||||
def fail_cache_read(self: Path, *args: object, **kwargs: object) -> str:
|
||||
# chmod(0) cannot revoke read access on Windows, so fault the read
|
||||
# itself for a platform-independent OSError
|
||||
if self == cache:
|
||||
raise OSError("permission denied")
|
||||
return real_read_text(self, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(Path, "read_text", fail_cache_read),
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
|
||||
"""A compile DB naming a launcher as the compiler is rejected, never cached."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
cache = tmp_path / "c.json"
|
||||
# No probe patch needed: the launcher is rejected before the probe runs
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.load_or_build_idedata(compile_commands, tmp_path / "f.elf", cache)
|
||||
assert not cache.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached",
|
||||
(
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}},
|
||||
),
|
||||
ids=("launcher-cxx", "no-includes", "no-build-list"),
|
||||
)
|
||||
def test_load_or_build_idedata_regenerates_invalid_cache(
|
||||
tmp_path: Path, cached: dict
|
||||
) -> None:
|
||||
"""A cache written by an older version fails validation and regenerates."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(json.dumps(cached))
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "includes" in data
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None:
|
||||
"""A served cache carries the current ELF path, not the one it was written with."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": [], "toolchain": []},
|
||||
"prog_path": "/old/location/firmware.elf",
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
|
||||
|
||||
def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not a list raises by name, inside the best-effort tuple."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
for bad in ("{}", "null", '"text"', '["a", "b"]', "[1, 2]"):
|
||||
compile_commands.write_text(bad)
|
||||
with pytest.raises(EsphomeError, match="not a compile-command list"):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_same_file_rsp_commands_never_dedupe(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two objects built from one source with different .rsp files keep both
|
||||
include sets; the rsp sentinel keys on the output, not the source."""
|
||||
file = f"{ABS}build/src/esphome/core/shared.cpp"
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
|
||||
"""A valid cache newer than the compile DB is served without re-parsing."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": ["/inc"], "toolchain": []},
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
assert data["cached"] is True
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for esphome.build_helpers.ninja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ninja as ninja_helper
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("shutil.which", return_value=str(tmp_path / "ninja")),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=True),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / "ninja"
|
||||
|
||||
|
||||
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
|
||||
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_find_ninja_package_not_installed() -> None:
|
||||
"""A missing ninja package raises the actionable message, not ImportError."""
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": None}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_escape_ninja_specials() -> None:
|
||||
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
|
||||
|
||||
|
||||
def _q(tok: str) -> str:
|
||||
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
|
||||
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
|
||||
|
||||
|
||||
def test_quote_arg_windows_argv_rule() -> None:
|
||||
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
|
||||
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
|
||||
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
|
||||
|
||||
|
||||
def test_shell_token_quotes_only_when_needed() -> None:
|
||||
assert ninja_helper.shell_token("-Os") == "-Os"
|
||||
assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y")
|
||||
assert ninja_helper.shell_token("plain", force=True) == _q("plain")
|
||||
|
||||
|
||||
def test_shell_token_quotes_shell_metacharacters() -> None:
|
||||
"""Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare."""
|
||||
assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)")
|
||||
assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b")
|
||||
assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME")
|
||||
|
||||
|
||||
def test_shell_token_posix_roundtrips_through_sh() -> None:
|
||||
"""Backslash runs, $, backticks, and quotes must reach the compiler
|
||||
exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
pytest.skip("POSIX sh quoting")
|
||||
for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'):
|
||||
quoted = ninja_helper.shell_token(tok).replace("$$", "$")
|
||||
out = subprocess.run(
|
||||
["/bin/sh", "-c", f'printf "%s" {quoted}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert out.stdout == tok
|
||||
|
||||
|
||||
def test_quote_path_force_quotes() -> None:
|
||||
assert ninja_helper.quote_path(Path("a b")) == _q("a b")
|
||||
assert ninja_helper.quote_path("simple") == _q("simple")
|
||||
|
||||
|
||||
def test_shell_token_empty_token_is_quoted() -> None:
|
||||
"""An empty argv element must survive as an explicit pair of quotes."""
|
||||
assert ninja_helper.shell_token("") == _q("")
|
||||
|
||||
|
||||
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
|
||||
"""A broken PATH shim falls back to the wheel instead of failing every
|
||||
build later."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value="/broken/ninja"),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=False),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")):
|
||||
assert ninja_helper._ninja_runs("/broken/ninja") is False
|
||||
assert "failed to run" in caplog.text
|
||||
|
||||
|
||||
def test_ninja_probe_success() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_shell_token_windows_branch_uses_argv_rule() -> None:
|
||||
"""The nt branch quotes with the CreateProcess argv rule (the ubuntu
|
||||
coverage run never takes it naturally)."""
|
||||
with patch.object(os, "name", "nt"):
|
||||
assert ninja_helper.shell_token("a b") == '"a b"'
|
||||
assert ninja_helper.shell_token("", force=True) == '""'
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for the shared PlatformIO-format size bar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers.size_summary import format_bar, print_size_line
|
||||
|
||||
|
||||
def test_format_bar_zero_total() -> None:
|
||||
"""A zero total must not divide by zero."""
|
||||
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
|
||||
|
||||
|
||||
def test_print_size_line_label_padding(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The label column is exactly what ci_memory_impact_extract.py greps."""
|
||||
print_size_line("RAM", 47932, 180736)
|
||||
print_size_line("Flash", 888511, 1835008)
|
||||
out = capsys.readouterr().out.splitlines()
|
||||
assert out[0].startswith("RAM: [")
|
||||
assert out[1].startswith("Flash: [")
|
||||
assert "26.5% (used 47932 bytes from 180736 bytes)" in out[0]
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Invariant tests for esphome/components/api/api.proto and its generated code.
|
||||
|
||||
These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition
|
||||
(API 1.15) against regressions that protoc-based codegen would not catch on
|
||||
its own, without requiring protoc to be installed at test time:
|
||||
|
||||
* script/api_protobuf/api_protobuf.py skips any field marked
|
||||
`[deprecated = true]` completely -- it generates no C++ for it at all, so
|
||||
the device silently stops sending that value. Six DeviceInfoResponse fields
|
||||
were superseded by DeviceCapabilitiesResponse but must keep being sent for
|
||||
backward compatibility with clients older than API 1.15. If a future edit
|
||||
"tidies up" by marking one of them deprecated, this file breaks that field
|
||||
for every existing client with nothing else in CI noticing.
|
||||
* Field numbers are the wire protocol, not the field names. Renaming a field
|
||||
is harmless; renumbering it is a silent breaking change, because an old
|
||||
client still decodes by number. This file pins the field number of each of
|
||||
the six superseded DeviceInfoResponse fields and of every field on the new
|
||||
DeviceCapabilitiesResponse/BluetoothProxyCapabilities/
|
||||
VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a
|
||||
well-intentioned reshuffle of api.proto gets caught here instead of on a
|
||||
device in the field.
|
||||
* Message wire ids must be unique, and the new capabilities RPC must stay
|
||||
authenticated-only.
|
||||
|
||||
Group A below asserts on the checked-in generated files (api_pb2.h /
|
||||
api_pb2.cpp), since "the field is present in the generated C++" is exactly
|
||||
equivalent to "the device still sends it". Group B parses api.proto as plain
|
||||
text (no protoc). Group C checks the advertised API minor version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import esphome
|
||||
|
||||
API_DIR = Path(esphome.__file__).parent / "components" / "api"
|
||||
|
||||
PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8")
|
||||
HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8")
|
||||
CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8")
|
||||
API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8")
|
||||
|
||||
# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse
|
||||
# as of API 1.15 but must still be generated (and therefore still sent) for
|
||||
# backward compatibility with older clients.
|
||||
SUPERSEDED_FIELDS: dict[str, int] = {
|
||||
"bluetooth_proxy_feature_flags": 15,
|
||||
"voice_assistant_feature_flags": 17,
|
||||
"bluetooth_mac_address": 18,
|
||||
"zwave_proxy_feature_flags": 23,
|
||||
"zwave_home_id": 24,
|
||||
"serial_proxies": 25,
|
||||
}
|
||||
|
||||
# Field numbers on the new capability messages. These are a frozen wire
|
||||
# contract from the moment they ship: an old client decodes a sub-message
|
||||
# field purely by number, so renumbering any of these -- even without
|
||||
# touching a name -- silently corrupts what every already-deployed client
|
||||
# reads. Keyed by message name so the next capability sub-message is a
|
||||
# data-only addition here.
|
||||
NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
|
||||
"DeviceCapabilitiesResponse": {
|
||||
"bluetooth_proxy": 1,
|
||||
"voice_assistant": 2,
|
||||
"zwave_proxy": 3,
|
||||
"serial_proxies": 4,
|
||||
},
|
||||
"BluetoothProxyCapabilities": {
|
||||
"feature_flags": 1,
|
||||
"mac_address": 2,
|
||||
},
|
||||
"VoiceAssistantCapabilities": {
|
||||
"feature_flags": 1,
|
||||
},
|
||||
"ZWaveProxyCapabilities": {
|
||||
"feature_flags": 1,
|
||||
"home_id": 2,
|
||||
},
|
||||
}
|
||||
|
||||
# Fields that are genuinely dead and are expected to carry `deprecated=true`.
|
||||
# Used to prove the deprecated-detection logic below actually detects
|
||||
# deprecation rather than trivially passing.
|
||||
GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = (
|
||||
"legacy_bluetooth_proxy_version",
|
||||
"legacy_voice_assistant_version",
|
||||
)
|
||||
|
||||
DEPRECATED_FIELD_TRAP = (
|
||||
"script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = "
|
||||
"true]` completely, generating no C++ for them at all. Marking this field "
|
||||
"deprecated would silently stop the device from ever sending it, breaking "
|
||||
"every existing client that still reads it from DeviceInfoResponse."
|
||||
)
|
||||
|
||||
|
||||
def _extract_braced_region(text: str, anchor_pattern: str) -> str:
|
||||
"""Return the region of `text` starting at the first match of
|
||||
`anchor_pattern` up to the matching closing brace (inclusive), using
|
||||
brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop
|
||||
inside a function body) don't cause a premature stop.
|
||||
"""
|
||||
anchor_match = re.search(anchor_pattern, text)
|
||||
if anchor_match is None:
|
||||
raise AssertionError(f"could not find a match for {anchor_pattern!r}")
|
||||
start = anchor_match.start()
|
||||
open_brace = text.index("{", start)
|
||||
depth = 0
|
||||
for i in range(open_brace, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}")
|
||||
|
||||
|
||||
def _extract_class_body(header_text: str, class_name: str) -> str:
|
||||
"""Return the body of a generated C++ class, scoped so a field name that
|
||||
also happens to exist on some other class cannot satisfy the assertion.
|
||||
"""
|
||||
return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b")
|
||||
|
||||
|
||||
def _extract_function_body(cpp_text: str, qualified_name: str) -> str:
|
||||
"""Return the body of a generated `Class::method(...)` definition."""
|
||||
return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(")
|
||||
|
||||
|
||||
def _extract_proto_message(proto_text: str, message_name: str) -> str:
|
||||
"""Return the body of a top-level `message Name { ... }` block from the
|
||||
.proto source. Proto message bodies here contain no nested `{`/`}` of
|
||||
their own (options use parens, not braces), so a non-greedy match up to
|
||||
the first line that is just `}` is sufficient and keeps the parsing
|
||||
simple.
|
||||
"""
|
||||
match = re.search(
|
||||
rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}",
|
||||
proto_text,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"could not find `message {message_name}` in api.proto")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _extract_rpc_body(proto_text: str, rpc_name: str) -> str:
|
||||
"""Return the option body of an `rpc name (...) returns (...) { ... }`
|
||||
declaration from the APIConnection service, robust to it being written
|
||||
on one line (`{}`) or spread across several with options inside.
|
||||
"""
|
||||
match = re.search(
|
||||
rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}",
|
||||
proto_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _field_declaration_line(message_body: str, field_name: str) -> str:
|
||||
"""Return the single source line declaring `field_name` inside a proto
|
||||
message body (all fields here are declared on one line).
|
||||
"""
|
||||
for line in message_body.splitlines():
|
||||
if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line):
|
||||
return line
|
||||
raise AssertionError(
|
||||
f"could not find a field declaration for {field_name!r} in the given message body"
|
||||
)
|
||||
|
||||
|
||||
# ==================== Group A: generated files ====================
|
||||
|
||||
|
||||
def test_superseded_device_info_fields_still_declared_in_header() -> None:
|
||||
"""Each superseded field must still be a real member of DeviceInfoResponse
|
||||
in api_pb2.h -- not merely present somewhere in the file. Several of these
|
||||
names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an
|
||||
unscoped substring search over the whole header would pass even if the
|
||||
field were removed from DeviceInfoResponse.
|
||||
"""
|
||||
class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse")
|
||||
for field_name in SUPERSEDED_FIELDS:
|
||||
assert re.search(rf"\b{field_name}\b", class_body), (
|
||||
f"{field_name} is missing from the DeviceInfoResponse class body in "
|
||||
f"api_pb2.h. {DEPRECATED_FIELD_TRAP}"
|
||||
)
|
||||
|
||||
|
||||
def test_superseded_device_info_fields_still_encoded_and_sized() -> None:
|
||||
"""Each superseded field must still be touched by DeviceInfoResponse's
|
||||
generated encode() and calculate_size(), i.e. it is still put on the wire.
|
||||
"""
|
||||
encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode")
|
||||
size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size")
|
||||
for field_name in SUPERSEDED_FIELDS:
|
||||
assert f"this->{field_name}" in encode_body, (
|
||||
f"DeviceInfoResponse::encode() no longer references {field_name}. "
|
||||
f"{DEPRECATED_FIELD_TRAP}"
|
||||
)
|
||||
assert f"this->{field_name}" in size_body, (
|
||||
f"DeviceInfoResponse::calculate_size() no longer references "
|
||||
f"{field_name}. {DEPRECATED_FIELD_TRAP}"
|
||||
)
|
||||
|
||||
|
||||
def test_new_capability_classes_present_in_header() -> None:
|
||||
"""The new response message and its capability sub-messages must exist as
|
||||
generated classes.
|
||||
"""
|
||||
for class_name in (
|
||||
"DeviceCapabilitiesResponse",
|
||||
"BluetoothProxyCapabilities",
|
||||
"VoiceAssistantCapabilities",
|
||||
"ZWaveProxyCapabilities",
|
||||
):
|
||||
assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), (
|
||||
f"expected a generated class named {class_name} in api_pb2.h"
|
||||
)
|
||||
|
||||
|
||||
# ==================== Group B: api.proto source text ====================
|
||||
|
||||
|
||||
def test_all_message_ids_are_unique() -> None:
|
||||
"""Every `option (id) = N;` in api.proto must be unique. Two messages
|
||||
sharing a wire id would make the client and server misinterpret each
|
||||
other's messages -- nothing else currently checks this.
|
||||
"""
|
||||
ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)]
|
||||
assert ids, "did not find any `option (id) = N;` declarations in api.proto"
|
||||
duplicates = sorted({value for value in ids if ids.count(value) > 1})
|
||||
assert not duplicates, (
|
||||
f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each "
|
||||
"message must have a unique wire id."
|
||||
)
|
||||
|
||||
|
||||
def test_device_capabilities_request_has_id_149() -> None:
|
||||
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest")
|
||||
match = re.search(r"option \(id\) = (\d+);", body)
|
||||
assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`"
|
||||
assert int(match.group(1)) == 149, (
|
||||
f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. "
|
||||
"Message ids are part of the wire protocol and must not change once "
|
||||
"assigned."
|
||||
)
|
||||
|
||||
|
||||
def test_device_capabilities_response_has_id_150() -> None:
|
||||
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse")
|
||||
match = re.search(r"option \(id\) = (\d+);", body)
|
||||
assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`"
|
||||
assert int(match.group(1)) == 150, (
|
||||
f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. "
|
||||
"Message ids are part of the wire protocol and must not change once "
|
||||
"assigned."
|
||||
)
|
||||
|
||||
|
||||
def test_z_wave_proxy_request_response_has_id_151() -> None:
|
||||
body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse")
|
||||
match = re.search(r"option \(id\) = (\d+);", body)
|
||||
assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`"
|
||||
assert int(match.group(1)) == 151, (
|
||||
f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. "
|
||||
"Message ids are part of the wire protocol and must not change once "
|
||||
"assigned."
|
||||
)
|
||||
|
||||
|
||||
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
|
||||
"""The six superseded fields must not carry `[deprecated = true]` in
|
||||
api.proto, or the generator drops them and old clients stop receiving
|
||||
them (see module docstring). The second half of this test proves the
|
||||
deprecated-detection itself works: two genuinely dead fields
|
||||
(legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must
|
||||
still be detected as deprecated, so the first half isn't vacuously true.
|
||||
"""
|
||||
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
|
||||
|
||||
for field_name in SUPERSEDED_FIELDS:
|
||||
line = _field_declaration_line(body, field_name)
|
||||
assert "deprecated" not in line, (
|
||||
f"{field_name} in DeviceInfoResponse is marked deprecated in "
|
||||
f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}"
|
||||
)
|
||||
|
||||
for field_name in GENUINELY_DEPRECATED_FIELDS:
|
||||
line = _field_declaration_line(body, field_name)
|
||||
assert "deprecated" in line, (
|
||||
f"expected {field_name} to still carry `deprecated=true` in "
|
||||
f"api.proto ({line.strip()!r}). If this fails, the deprecated "
|
||||
"detection used above is broken, and the sibling assertion that "
|
||||
"the superseded fields are NOT deprecated is not testing anything."
|
||||
)
|
||||
|
||||
|
||||
def test_superseded_fields_keep_their_wire_numbers() -> None:
|
||||
"""Each superseded field must stay on the field number recorded in
|
||||
SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field
|
||||
number, so renumbering one of these -- even without touching its name --
|
||||
would make an old client read a completely different value out of the
|
||||
wire, with nothing else in CI noticing.
|
||||
"""
|
||||
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
|
||||
|
||||
for field_name, field_number in SUPERSEDED_FIELDS.items():
|
||||
line = _field_declaration_line(body, field_name)
|
||||
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
|
||||
f"{field_name} in DeviceInfoResponse is no longer declared at "
|
||||
f"field number {field_number} ({line.strip()!r}). Field numbers "
|
||||
"are the wire protocol -- renumbering this field silently breaks "
|
||||
"every existing client that still decodes DeviceInfoResponse by "
|
||||
"the old numbering."
|
||||
)
|
||||
|
||||
|
||||
def test_capability_message_fields_keep_their_wire_numbers() -> None:
|
||||
"""Every field on DeviceCapabilitiesResponse and its three capability
|
||||
sub-messages must stay on the field number recorded in
|
||||
NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but
|
||||
the moment a device ships with them, their field numbers are a frozen
|
||||
wire contract -- a client decodes a sub-message field purely by number,
|
||||
so a later "cleanup" that renumbers one of these would silently corrupt
|
||||
what every already-deployed client reads, with nothing else in CI
|
||||
noticing.
|
||||
"""
|
||||
for message_name, fields in NEW_CAPABILITY_FIELDS.items():
|
||||
body = _extract_proto_message(PROTO_TEXT, message_name)
|
||||
for field_name, field_number in fields.items():
|
||||
line = _field_declaration_line(body, field_name)
|
||||
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
|
||||
f"{field_name} on {message_name} is no longer declared at "
|
||||
f"field number {field_number} ({line.strip()!r}). Field "
|
||||
"numbers are the wire protocol -- renumbering this field "
|
||||
"silently breaks every existing client that decodes this "
|
||||
"message by the old numbering."
|
||||
)
|
||||
|
||||
|
||||
def test_device_capabilities_rpc_requires_authentication() -> None:
|
||||
"""The `device_capabilities` RPC must not set
|
||||
`option (needs_authentication) = false;` (or set it to anything at all).
|
||||
Leaving it unset makes it inherit needs_authentication = true, keeping
|
||||
capability data behind authentication (and encryption, when configured).
|
||||
"""
|
||||
body = _extract_rpc_body(PROTO_TEXT, "device_capabilities")
|
||||
assert "needs_authentication" not in body, (
|
||||
"rpc device_capabilities sets a `needs_authentication` option in "
|
||||
"api.proto. It must stay unset so it inherits needs_authentication = "
|
||||
"true; otherwise device capability data could be requested over an "
|
||||
"unauthenticated connection."
|
||||
)
|
||||
|
||||
|
||||
# ==================== Group C: advertised API version ====================
|
||||
|
||||
|
||||
def test_api_version_minor_is_at_least_15() -> None:
|
||||
"""Clients gate sending DeviceCapabilitiesRequest on seeing
|
||||
api_version >= 1.15 in HelloResponse. Regressing api_version_minor below
|
||||
15 would make every client believe capabilities are unsupported even
|
||||
though the RPC exists, so this must never go backwards. Use >= rather
|
||||
than == so the next unrelated minor-version bump doesn't need to touch
|
||||
this test.
|
||||
"""
|
||||
match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT)
|
||||
assert match is not None, (
|
||||
"could not find `resp.api_version_minor = N;` in api_connection.cpp"
|
||||
)
|
||||
minor = int(match.group(1))
|
||||
assert minor >= 15, (
|
||||
f"api_version_minor is {minor}, but device_capabilities requires "
|
||||
"clients to see api_version >= 1.15 in HelloResponse before they will "
|
||||
"ever request it."
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Unit tests for script/api_protobuf/api_protobuf.py generator logic.
|
||||
|
||||
ci-api-proto.yml only checks that the committed output matches what the
|
||||
generator currently produces, so a semantic regression in the generator would
|
||||
be committed and matched without anything failing. These tests pin the
|
||||
semantics directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf"))
|
||||
|
||||
from api_protobuf import ( # noqa: E402
|
||||
MAX_MESSAGE_ID,
|
||||
_make_ifdef_line,
|
||||
get_varint64_ifdef,
|
||||
validate_message_id,
|
||||
)
|
||||
from google.protobuf import descriptor_pb2 # noqa: E402
|
||||
|
||||
|
||||
def _file_with_messages(
|
||||
*messages: tuple[str, int, bool],
|
||||
) -> descriptor_pb2.FileDescriptorProto:
|
||||
"""Build a FileDescriptorProto with one single-field message per entry.
|
||||
|
||||
Each entry is (message_name, field_type, deprecated).
|
||||
"""
|
||||
file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto")
|
||||
for name, field_type, deprecated in messages:
|
||||
msg = file_desc.message_type.add(name=name)
|
||||
field = msg.field.add(name="value", number=1, type=field_type)
|
||||
field.options.deprecated = deprecated
|
||||
return file_desc
|
||||
|
||||
|
||||
UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64
|
||||
INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64
|
||||
SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64
|
||||
UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32
|
||||
FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64
|
||||
|
||||
|
||||
def test_no_varint64_fields() -> None:
|
||||
file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False))
|
||||
assert get_varint64_ifdef(file_desc, {}) == (False, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64])
|
||||
def test_single_guard_is_kept(field_type: int) -> None:
|
||||
file_desc = _file_with_messages(("A", field_type, False))
|
||||
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X")
|
||||
|
||||
|
||||
def test_two_guards_emit_the_union() -> None:
|
||||
# The regression this pins: multiple guards used to collapse to
|
||||
# unconditional, pulling 64-bit varint support into unrelated builds.
|
||||
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
|
||||
guards = {"A": "USE_X", "B": "USE_Y"}
|
||||
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
|
||||
|
||||
|
||||
def test_union_is_sorted_for_deterministic_output() -> None:
|
||||
file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False))
|
||||
guards = {"B": "USE_Y", "A": "USE_X"}
|
||||
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
|
||||
|
||||
|
||||
def test_any_unconditional_message_wins() -> None:
|
||||
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
|
||||
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None)
|
||||
|
||||
|
||||
def test_deprecated_fields_and_messages_are_ignored() -> None:
|
||||
file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False))
|
||||
file_desc.message_type[1].options.deprecated = True
|
||||
assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None)
|
||||
|
||||
|
||||
def test_make_ifdef_line_simple_identifier() -> None:
|
||||
assert _make_ifdef_line("USE_X") == "#ifdef USE_X"
|
||||
|
||||
|
||||
def test_make_ifdef_line_union_wraps_each_identifier() -> None:
|
||||
# The second half of the varint64 union guard: compound conditions must
|
||||
# become #if defined(A) || defined(B), never #ifdef of the raw string.
|
||||
assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)"
|
||||
|
||||
|
||||
def test_make_ifdef_line_conjunction_and_negation() -> None:
|
||||
assert (
|
||||
_make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)"
|
||||
)
|
||||
|
||||
|
||||
def test_message_id_at_maximum_is_accepted() -> None:
|
||||
# 16383 is the largest ID whose plaintext type varint fits the 2 bytes
|
||||
# budgeted in HEADER_PADDING.
|
||||
validate_message_id(MAX_MESSAGE_ID, "MaxMessage")
|
||||
|
||||
|
||||
def test_message_id_above_maximum_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="exceeds the plaintext"):
|
||||
validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage")
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Tests for esphome.components.api.client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.components import esp32
|
||||
from esphome.components.api import client as api_client
|
||||
from esphome.core import 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.
|
||||
"""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
with patch.object(
|
||||
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
|
||||
) as mock_process:
|
||||
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
|
||||
processor.process_line("PC: 0x4010496e")
|
||||
|
||||
assert mock_process.called
|
||||
assert processor.backtrace_state is False
|
||||
|
||||
|
||||
def test_decoder_swallows_platform_handler_error() -> None:
|
||||
"""The same protection must apply to the platform-specific handler."""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
def platform_handler(_config, _line, _state):
|
||||
raise EsphomeError("no idedata")
|
||||
|
||||
processor = api_client._LogLineProcessor(config, platform_handler)
|
||||
processor.process_line("PC: 0x4010496e")
|
||||
|
||||
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.
|
||||
"""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()):
|
||||
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
|
||||
processor.process_line("PC: 0x4010496e")
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert any("build artifacts not found locally" in m for m in warnings)
|
||||
assert not any("()" in m for m in warnings)
|
||||
|
||||
|
||||
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
|
||||
PC/BT lines and retrying the failing subprocess for each one would
|
||||
stall log streaming.
|
||||
"""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
with patch.object(
|
||||
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
|
||||
) as mock_process:
|
||||
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
|
||||
processor.process_line("PC: 0x4010496e")
|
||||
processor.process_line("BT0: 0x4010496e")
|
||||
processor.process_line("BT1: 0x401049aa")
|
||||
|
||||
assert mock_process.call_count == 1
|
||||
|
||||
|
||||
def test_decoder_threads_backtrace_state() -> None:
|
||||
"""When decoding succeeds, backtrace_state is threaded across calls."""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
|
||||
with patch.object(
|
||||
esp32, "process_stacktrace", side_effect=[True, False]
|
||||
) as mock_process:
|
||||
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
|
||||
processor.process_line(">>>stack>>>")
|
||||
assert processor.backtrace_state is True
|
||||
processor.process_line("<<<stack<<<")
|
||||
assert processor.backtrace_state is False
|
||||
|
||||
assert not mock_process.call_args_list[0].args[-1]
|
||||
assert mock_process.call_args_list[1].args[-1]
|
||||
|
||||
|
||||
def test_decoder_uses_platform_handler_when_provided() -> None:
|
||||
"""The platform handler is preferred over the generic one."""
|
||||
config = {"esphome": {"name": "test"}}
|
||||
calls: list[tuple[object, str, bool]] = []
|
||||
|
||||
def platform_handler(cfg, line, state):
|
||||
calls.append((cfg, line, state))
|
||||
return True
|
||||
|
||||
processor = api_client._LogLineProcessor(config, platform_handler)
|
||||
|
||||
with patch.object(esp32, "process_stacktrace") as mock_generic:
|
||||
processor.process_line("BT0: 0x4010496e")
|
||||
|
||||
assert calls == [(config, "BT0: 0x4010496e", False)]
|
||||
assert mock_generic.called is False
|
||||
assert processor.backtrace_state is True
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the bme68x_bsec2 prefetch extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components import bme68x_bsec2 as bsec
|
||||
from esphome.loader import get_component
|
||||
|
||||
|
||||
def test_prefetch_applies_defaults(setup_core: Path) -> None:
|
||||
[files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}]))
|
||||
assert len(files) == 1
|
||||
assert "bme680_iaq_33v_3s_28d" in files[0].url
|
||||
assert files[0].path == bsec._compute_local_file_path(files[0].url)
|
||||
|
||||
|
||||
def test_prefetch_normalizes_enum_case(setup_core: Path) -> None:
|
||||
[files] = list(
|
||||
bsec.PREFETCH_FILES(
|
||||
[
|
||||
{
|
||||
"model": "BME688",
|
||||
"sample_rate": "ulp",
|
||||
"supply_voltage": "1.8v",
|
||||
"algorithm_output": "REGRESSION",
|
||||
"operating_age": "4D",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
assert len(files) == 1
|
||||
assert "bme688_reg_18v_300s_4d" in files[0].url
|
||||
|
||||
|
||||
def test_prefetch_skips_unknown_values(setup_core: Path) -> None:
|
||||
entries = [
|
||||
{"model": "bme999"},
|
||||
{"model": "bme680", "sample_rate": "TURBO"},
|
||||
{"model": "bme680", "algorithm_output": "psychic"},
|
||||
{},
|
||||
]
|
||||
assert list(bsec.PREFETCH_FILES(entries)) == [[]]
|
||||
|
||||
|
||||
def test_prefetch_matches_validator_url(setup_core: Path) -> None:
|
||||
"""The hook's URL equals _compute_url over the validated config shape."""
|
||||
validated = {
|
||||
"model": "bme688",
|
||||
"operating_age": "28d",
|
||||
"sample_rate": "LP",
|
||||
"supply_voltage": "3.3V",
|
||||
"algorithm_output": "classification",
|
||||
}
|
||||
[files] = list(bsec.PREFETCH_FILES([dict(validated)]))
|
||||
assert files[0].url == bsec._compute_url(validated)
|
||||
|
||||
|
||||
def test_hook_is_wired_to_the_user_facing_domain() -> None:
|
||||
"""The i2c domain (the only user-facing one) exposes the hook."""
|
||||
|
||||
component = get_component("bme68x_bsec2_i2c")
|
||||
assert component is not None
|
||||
assert component.prefetch_files is bsec.PREFETCH_FILES
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for the esp32 sdkconfig write and its toolchain-gated clean."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import _write_sdkconfig
|
||||
from esphome.components.esp32.const import KEY_SDKCONFIG_OPTIONS
|
||||
from esphome.const import KEY_CORE, KEY_ESP32, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf.toolchain import has_outdated_files
|
||||
|
||||
|
||||
def _setup_core(tmp_path: Path, toolchain: Toolchain | None) -> None:
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.toolchain = toolchain
|
||||
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {"CONFIG_X": "y"}}
|
||||
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: "5.5.5"}
|
||||
|
||||
|
||||
def _seed_configured_build(tmp_path: Path) -> None:
|
||||
"""A settled native build: configure outputs predate what comes next."""
|
||||
build = tmp_path / "build"
|
||||
(build / "config").mkdir(parents=True)
|
||||
(build / "config" / "sdkconfig.h").write_text("")
|
||||
(build / "CMakeCache.txt").write_text("")
|
||||
(build / "build.ninja").write_text("")
|
||||
# Explicitly older than what the test writes next: has_outdated_files()
|
||||
# compares st_mtime with a strict >, so same-tick writes would pass
|
||||
past = time.time() - 60
|
||||
for f in build.rglob("*"):
|
||||
os.utime(f, (past, past))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "clean_expected"),
|
||||
[(Toolchain.ESP_IDF, False), (Toolchain.PLATFORMIO, True), (None, True)],
|
||||
)
|
||||
def test_write_sdkconfig_cleans_only_on_platformio(
|
||||
tmp_path: Path, toolchain: Toolchain | None, clean_expected: bool
|
||||
) -> None:
|
||||
"""A changed sdkconfig forces a full clean only under PlatformIO; the
|
||||
esp-idf toolchain reconfigures via has_outdated_files() instead; an
|
||||
unresolved toolchain fails safe onto the clean."""
|
||||
_setup_core(tmp_path, toolchain)
|
||||
_seed_configured_build(tmp_path)
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.components.esp32.clean_build") as clean,
|
||||
):
|
||||
_write_sdkconfig()
|
||||
assert "CONFIG_X" in CORE.relative_build_path("sdkconfig.test").read_text()
|
||||
assert clean.called is clean_expected
|
||||
if clean_expected:
|
||||
clean.assert_called_once_with(clear_pio_cache=False)
|
||||
# The change must still trigger a reconfigure: the internal
|
||||
# sdkconfig snapshot is now newer than build/CMakeCache.txt
|
||||
assert has_outdated_files() is True
|
||||
clean.reset_mock()
|
||||
# A settled configure restamps the cache; an unchanged rewrite
|
||||
# must then neither clean nor mark the build stale
|
||||
future = time.time() + 60
|
||||
os.utime(CORE.relative_build_path("build/CMakeCache.txt"), (future, future))
|
||||
_write_sdkconfig()
|
||||
clean.assert_not_called()
|
||||
assert has_outdated_files() is False
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for the per-board linker-script rule."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import _choose_ld_script
|
||||
from esphome.components.esp8266.boards import BOARDS, board_ld_script
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
def test_d1_wroom_02_keeps_its_shipped_layout() -> None:
|
||||
"""The override must survive a BOARDS regeneration or key typo: the
|
||||
2m.ld default moves _FS_end and the preferences sector on deployed
|
||||
devices."""
|
||||
assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld"
|
||||
|
||||
|
||||
def test_default_boards_use_the_flash_size_layout() -> None:
|
||||
assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld"
|
||||
assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld"
|
||||
|
||||
|
||||
def test_choose_ld_script_paths() -> None:
|
||||
"""Old cores get the size default, overriding boards hard-error there
|
||||
(a substituted layout would wipe flash-backed state), modern cores
|
||||
honor the override."""
|
||||
assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None
|
||||
assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld"
|
||||
assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == (
|
||||
"eagle.flash.2m64.ld"
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="cannot honor"):
|
||||
_choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for the linker-script surgery shared with the native toolchain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
|
||||
from esphome.components.esp8266.build_surgery import (
|
||||
RATETABLE_RULE,
|
||||
apply_testing_memory_patches,
|
||||
relocate_ratetable,
|
||||
segment_length,
|
||||
)
|
||||
|
||||
_COMMON_LD_SNIPPET = """\
|
||||
.dport0.data : ALIGN(4)
|
||||
{
|
||||
_dport0_data_start = ABSOLUTE(.);
|
||||
} >dport0_0_seg :dport0_0_phdr
|
||||
.data : ALIGN(4)
|
||||
{
|
||||
_data_start = ABSOLUTE(.);
|
||||
*(.data)
|
||||
} >dram0_0_seg :dram0_0_phdr
|
||||
"""
|
||||
|
||||
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
|
||||
# the generated common ld only)
|
||||
_FLASH_LD_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
dport0_0_seg : org = 0x3FF00000, len = 0x10
|
||||
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
|
||||
irom0_0_seg : org = 0x40201010, len = 0xfeff0
|
||||
}
|
||||
"""
|
||||
|
||||
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
|
||||
# suffix the patcher must leave in place
|
||||
_COMMON_LD_MEMORY_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
iram1_0_seg : org = 0x40100000, len = 0x8000ul
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_relocate_ratetable_inserts_after_data_start() -> None:
|
||||
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
|
||||
assert RATETABLE_RULE in patched
|
||||
# Inserted after the .data section's anchor, not the .dport0.data one
|
||||
# (whose closing brace bounds the decoy block)
|
||||
assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")]
|
||||
assert patched.index(RATETABLE_RULE) < patched.index("*(.data)")
|
||||
# Idempotent on an already-patched script
|
||||
assert relocate_ratetable(patched) == patched
|
||||
|
||||
|
||||
def test_relocate_ratetable_requires_anchor() -> None:
|
||||
with pytest.raises(RuntimeError, match="_data_start"):
|
||||
relocate_ratetable("SECTIONS { }")
|
||||
|
||||
|
||||
def test_testing_memory_patches_enlarge_segments() -> None:
|
||||
patched = apply_testing_memory_patches(
|
||||
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
|
||||
)
|
||||
assert segment_length(patched, "dram0_0_seg") == 0x200000
|
||||
assert segment_length(patched, "irom0_0_seg") == 0x2000000
|
||||
# Untouched segments keep their sizes
|
||||
assert segment_length(patched, "dport0_0_seg") == 0x10
|
||||
|
||||
|
||||
def test_testing_memory_patches_keep_ul_suffix() -> None:
|
||||
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
|
||||
replace only the hex digits, as testing_mode.py.script does."""
|
||||
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
|
||||
assert "len = 0x200000ul" in patched
|
||||
assert segment_length(patched, "iram1_0_seg") == 0x200000
|
||||
|
||||
|
||||
def test_segment_length_requires_whole_name() -> None:
|
||||
"""A name must match its own line, never inside a longer segment name."""
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_unknown_segment_raises() -> None:
|
||||
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
|
||||
|
||||
|
||||
def test_segment_length() -> None:
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_missing_segment_raises() -> None:
|
||||
"""A named segment the patch could not find raises instead of silently
|
||||
keeping the real memory limits."""
|
||||
with pytest.raises(RuntimeError, match="dram0_0_seg"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",))
|
||||
|
||||
|
||||
def test_board_build_covers_every_board() -> None:
|
||||
"""Every supported board has native build metadata (the table may carry
|
||||
extras that BOARDS does not expose)."""
|
||||
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
|
||||
|
||||
|
||||
def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
|
||||
"""The properties the linker-script cache depends on: the fingerprint is
|
||||
stable across calls and changes when the module's source changes."""
|
||||
|
||||
first = build_surgery.surgery_fingerprint()
|
||||
assert first == build_surgery.surgery_fingerprint()
|
||||
assert len(first) == 64
|
||||
int(first, 16) # sha256 hex digest
|
||||
|
||||
# A modified copy of the module must fingerprint differently
|
||||
copy = tmp_path / "build_surgery_variant.py"
|
||||
copy.write_text(
|
||||
Path(build_surgery.__file__).read_text(encoding="utf-8")
|
||||
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("build_surgery_variant", copy)
|
||||
variant = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = variant
|
||||
try:
|
||||
spec.loader.exec_module(variant)
|
||||
assert variant.surgery_fingerprint() != first
|
||||
finally:
|
||||
del sys.modules[spec.name]
|
||||
|
||||
|
||||
def test_testing_memory_patches_present_but_unselected_raises() -> None:
|
||||
"""A known segment left off the caller's list must fail, not silently
|
||||
keep its real memory limit."""
|
||||
with pytest.raises(RuntimeError, match="not selected"):
|
||||
apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for the file image platform's prefetch extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.components.file import image as file_image
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.loader import get_component, get_platform
|
||||
|
||||
|
||||
def test_extract_mdi_shorthand(setup_core: Path) -> None:
|
||||
ref = file_image._extract_file_ref("mdi:home")
|
||||
assert ref is not None
|
||||
assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg"
|
||||
assert ref.path.name == "home.svg"
|
||||
assert ref.path.parent.name == "mdi"
|
||||
|
||||
|
||||
def test_extract_web_url(setup_core: Path) -> None:
|
||||
url = "https://example.com/img.png"
|
||||
ref = file_image._extract_file_ref(url)
|
||||
assert ref == RemoteFile(url, file_image.compute_local_image_path(url))
|
||||
|
||||
|
||||
def test_extract_typed_dicts(setup_core: Path) -> None:
|
||||
url = "https://example.com/img.png"
|
||||
assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile(
|
||||
url, file_image.compute_local_image_path(url)
|
||||
)
|
||||
ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"})
|
||||
assert ref is not None
|
||||
assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg"
|
||||
|
||||
|
||||
def test_extract_skips_local_and_garbage(setup_core: Path) -> None:
|
||||
assert file_image._extract_file_ref("images/local.png") is None
|
||||
assert file_image._extract_file_ref("mdi:not a valid icon!") is None
|
||||
assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None
|
||||
assert file_image._extract_file_ref(42) is None
|
||||
assert file_image._extract_file_ref(None) is None
|
||||
|
||||
|
||||
def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None:
|
||||
entries = [
|
||||
{"file": "mdi:home"},
|
||||
{"file": "images/local.png"},
|
||||
{"file": "https://example.com/img.png"},
|
||||
{"no_file_key": True},
|
||||
]
|
||||
[files] = list(file_image.PREFETCH_FILES(entries))
|
||||
assert len(files) == 2
|
||||
assert files[0].url.endswith("home.svg")
|
||||
assert files[1].url == "https://example.com/img.png"
|
||||
|
||||
|
||||
def test_extractor_matches_validator_path(setup_core: Path) -> None:
|
||||
"""The path the validator downloads to equals the extractor's path."""
|
||||
with patch(
|
||||
"esphome.components.file.image.external_files.download_content"
|
||||
) as mock_download:
|
||||
file_image.validate_file_shorthand("mdi:home")
|
||||
|
||||
validated_path = mock_download.call_args[0][1]
|
||||
assert validated_path == file_image._extract_file_ref("mdi:home").path
|
||||
|
||||
|
||||
def test_hook_is_wired_to_both_animation_domains() -> None:
|
||||
"""Both animation entry points expose the shared image hook."""
|
||||
|
||||
assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES
|
||||
assert (
|
||||
get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Tests for the font component's prefetch extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import external_files
|
||||
from esphome.components import font
|
||||
import esphome.config_validation as cv
|
||||
from esphome.external_files import RemoteFile
|
||||
|
||||
|
||||
def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict:
|
||||
return {"family": family, "weight": weight, "italic": italic}
|
||||
|
||||
|
||||
def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None:
|
||||
spec = font._extract_remote_font("gfonts://Roboto")
|
||||
assert spec is not None
|
||||
assert spec[font.CONF_FAMILY] == "Roboto"
|
||||
assert spec[font.CONF_WEIGHT] == 400
|
||||
assert spec[font.CONF_ITALIC] is False
|
||||
|
||||
|
||||
def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None:
|
||||
assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700
|
||||
assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500
|
||||
|
||||
|
||||
def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None:
|
||||
"""Boolean spellings the schema accepts are accepted by the extractor."""
|
||||
spec = font._extract_remote_font(
|
||||
{"type": "gfonts", "family": "Roboto", "italic": "true"}
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec[font.CONF_ITALIC] is True
|
||||
assert (
|
||||
font._extract_remote_font(
|
||||
{"type": "gfonts", "family": "Roboto", "italic": "maybe"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_extract_typed_gfonts_dict(setup_core: Path) -> None:
|
||||
spec = font._extract_remote_font(
|
||||
{"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True}
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec[font.CONF_WEIGHT] == 500
|
||||
assert spec[font.CONF_ITALIC] is True
|
||||
|
||||
|
||||
def test_extract_web_font(setup_core: Path) -> None:
|
||||
url = "https://example.com/font.ttf"
|
||||
for value in (url, {"type": "web", "url": url}):
|
||||
spec = font._extract_remote_font(value)
|
||||
assert spec is not None
|
||||
assert spec[font.CONF_URL] == url
|
||||
|
||||
|
||||
def test_extract_skips_local_and_garbage(setup_core: Path) -> None:
|
||||
assert font._extract_remote_font("fonts/local.ttf") is None
|
||||
assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None
|
||||
assert (
|
||||
font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"})
|
||||
is None
|
||||
)
|
||||
assert font._extract_remote_font(42) is None
|
||||
|
||||
|
||||
def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None:
|
||||
entries = [
|
||||
{"file": "gfonts://Roboto"},
|
||||
{"file": "fonts/local.ttf"},
|
||||
{
|
||||
"file": "https://example.com/font.ttf",
|
||||
"extras": [{"file": "gfonts://Monocraft"}],
|
||||
},
|
||||
]
|
||||
batches = list(font.PREFETCH_FILES(entries))
|
||||
urls = [file.url for file in batches[0]]
|
||||
assert font._gfonts_css_url(_gspec("Roboto")) in urls
|
||||
assert font._gfonts_css_url(_gspec("Monocraft")) in urls
|
||||
assert "https://example.com/font.ttf" in urls
|
||||
assert len(batches[0]) == 3
|
||||
|
||||
|
||||
def test_prefetch_skips_recent_ttf(setup_core: Path) -> None:
|
||||
path = font._gfonts_ttf_path(_gspec("Roboto"))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"cached ttf")
|
||||
|
||||
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
|
||||
assert batches == [[], []]
|
||||
|
||||
|
||||
def test_stage2_parses_cached_css(setup_core: Path) -> None:
|
||||
|
||||
css_path = font._gfonts_css_path(_gspec("Roboto"))
|
||||
css_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
css_path.write_text(
|
||||
"src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');"
|
||||
)
|
||||
# Stage two only trusts CSS confirmed fetched this run.
|
||||
external_files._run_data().fresh_paths.add(css_path)
|
||||
|
||||
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
|
||||
assert batches[1] == [
|
||||
RemoteFile(
|
||||
"https://fonts.gstatic.com/roboto.ttf",
|
||||
font._gfonts_ttf_path(_gspec("Roboto")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_stage2_skips_missing_css(setup_core: Path) -> None:
|
||||
batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}]))
|
||||
assert batches[1] == []
|
||||
|
||||
|
||||
def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None:
|
||||
"""A bare-mapping extras value (valid raw config) is scanned."""
|
||||
entries = [
|
||||
{
|
||||
"file": "fonts/local.ttf",
|
||||
"extras": {"file": "gfonts://Roboto", "glyphs": "ABC"},
|
||||
}
|
||||
]
|
||||
batches = list(font.PREFETCH_FILES(entries))
|
||||
assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))]
|
||||
|
||||
|
||||
def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None:
|
||||
"""A CSS body that fails to parse is removed from the cache."""
|
||||
|
||||
spec = {
|
||||
"family": "Roboto",
|
||||
"weight": 400,
|
||||
"italic": False,
|
||||
"refresh": font._REFRESH_VALIDATOR("0s"),
|
||||
}
|
||||
css_path = font._gfonts_css_path(spec)
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.font.external_files.download_content",
|
||||
return_value=b"no truetype url here",
|
||||
),
|
||||
patch(
|
||||
"esphome.components.font.external_files.is_fresh_this_run",
|
||||
return_value=True,
|
||||
),
|
||||
pytest.raises(cv.Invalid, match="please report this"),
|
||||
):
|
||||
font.download_gfont(spec)
|
||||
assert not css_path.exists()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.font.external_files.download_content",
|
||||
return_value=b"\xff\xfe\x00\x01binary",
|
||||
),
|
||||
patch(
|
||||
"esphome.components.font.external_files.is_fresh_this_run",
|
||||
return_value=True,
|
||||
),
|
||||
pytest.raises(cv.Invalid, match="not a text document"),
|
||||
):
|
||||
font.download_gfont(spec)
|
||||
assert not css_path.exists()
|
||||
|
||||
|
||||
def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None:
|
||||
"""A CSS body that could not be revalidated is not parsed for a ttf
|
||||
URL; the cached font is used instead."""
|
||||
spec = {
|
||||
"family": "Roboto",
|
||||
"weight": 400,
|
||||
"italic": False,
|
||||
"refresh": font._REFRESH_VALIDATOR("0s"),
|
||||
}
|
||||
ttf_path = font._gfonts_ttf_path(spec)
|
||||
ttf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ttf_path.write_bytes(b"cached ttf")
|
||||
cache = MagicMock()
|
||||
with (
|
||||
patch.object(font, "FONT_CACHE", cache),
|
||||
patch(
|
||||
"esphome.components.font.external_files.download_content",
|
||||
return_value=b"stale css",
|
||||
),
|
||||
):
|
||||
assert font.download_gfont(spec) is spec
|
||||
cache.__setitem__.assert_called_once_with(spec, ttf_path)
|
||||
|
||||
|
||||
def test_unrevalidated_gfonts_css_without_cached_font_errors(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""No verified CSS and no cached font is a clear error."""
|
||||
spec = {
|
||||
"family": "Roboto",
|
||||
"weight": 500,
|
||||
"italic": False,
|
||||
"refresh": font._REFRESH_VALIDATOR("0s"),
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.font.external_files.download_content",
|
||||
return_value=b"stale css",
|
||||
),
|
||||
pytest.raises(cv.Invalid, match="no cached font"),
|
||||
):
|
||||
font.download_gfont(spec)
|
||||
|
||||
|
||||
def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None:
|
||||
"""A leftover CSS from an earlier run is not trusted for stage two."""
|
||||
css_path = font._gfonts_css_path(_gspec("Roboto"))
|
||||
css_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
css_path.write_text(
|
||||
"src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');"
|
||||
)
|
||||
|
||||
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
|
||||
assert batches[1] == []
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the gsl3670 touchscreen prefetch extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.gsl3670 import touchscreen as gsl
|
||||
from esphome.external_files import RemoteFile
|
||||
|
||||
|
||||
def test_prefetch_explicit_url(setup_core: Path) -> None:
|
||||
url = "https://example.com/fw.bin"
|
||||
entries = [{"platform": "gsl3670", "firmware": {"url": url}}]
|
||||
assert list(gsl.PREFETCH_FILES(entries)) == [
|
||||
[RemoteFile(url, gsl._cache_path(url))]
|
||||
]
|
||||
|
||||
|
||||
def test_prefetch_model_default_firmware(setup_core: Path) -> None:
|
||||
entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}]
|
||||
[files] = list(gsl.PREFETCH_FILES(entries))
|
||||
assert len(files) == 1
|
||||
assert (
|
||||
files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"]
|
||||
)
|
||||
assert files[0].path == gsl._cache_path(files[0].url)
|
||||
|
||||
|
||||
def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None:
|
||||
entries = [
|
||||
{"platform": "gsl3670", "firmware": {"file": "fw.bin"}},
|
||||
{"platform": "gsl3670", "model": "CUSTOM"},
|
||||
{"platform": "gsl3670"},
|
||||
]
|
||||
assert list(gsl.PREFETCH_FILES(entries)) == [[]]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for the shared addressable-strip channel order helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
|
||||
from esphome.components.light import (
|
||||
channel_colors_struct,
|
||||
migrate_channel_colors,
|
||||
validate_channel_colors,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER
|
||||
from esphome.types import ConfigType
|
||||
|
||||
NO_WHITE = "light::ChannelColors::NO_WHITE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("RGB", "RGB"),
|
||||
("grb", "GRB"),
|
||||
("BRG", "BRG"),
|
||||
("rgbw", "RGBW"),
|
||||
("WRGB", "WRGB"),
|
||||
("GWRB", "GWRB"),
|
||||
],
|
||||
)
|
||||
def test_validate_channel_colors(value: str, expected: str) -> None:
|
||||
assert validate_channel_colors(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"RG", # missing a channel
|
||||
"RGBB", # duplicate channel
|
||||
"RRGB", # duplicate channel, correct length
|
||||
"RGBWW", # two white channels
|
||||
"RGBX", # unknown channel
|
||||
"RGBWX", # unknown channel, correct length
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_validate_channel_colors_rejects_invalid(value: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="is not a valid channel order"):
|
||||
validate_channel_colors(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("RGB", (0, 1, 2, NO_WHITE)),
|
||||
("GRB", (1, 0, 2, NO_WHITE)),
|
||||
("BRG", (1, 2, 0, NO_WHITE)),
|
||||
("RGBW", (0, 1, 2, 3)),
|
||||
("GRBW", (1, 0, 2, 3)),
|
||||
("WRGB", (1, 2, 3, 0)),
|
||||
("GWRB", (2, 0, 3, 1)),
|
||||
],
|
||||
)
|
||||
def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None:
|
||||
struct = channel_colors_struct(value)
|
||||
assert str(struct.base) == "light::ChannelColors"
|
||||
assert tuple(str(arg) for arg in struct.args.values()) == tuple(
|
||||
str(field) for field in expected
|
||||
)
|
||||
|
||||
|
||||
def _migrate(config: ConfigType) -> ConfigType:
|
||||
return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config)
|
||||
|
||||
|
||||
def test_migrate_passes_through_channel_colors() -> None:
|
||||
config = {CONF_CHANNEL_COLORS: "GRBW"}
|
||||
assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deprecated", "expected", "named"),
|
||||
[
|
||||
({}, "GRB", "'rgb_order' is"),
|
||||
(
|
||||
{CONF_IS_RGBW: False, CONF_IS_WRGB: False},
|
||||
"GRB",
|
||||
"'rgb_order', 'is_rgbw' and 'is_wrgb' are",
|
||||
),
|
||||
({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"),
|
||||
({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"),
|
||||
],
|
||||
)
|
||||
def test_migrate_folds_deprecated_keys(
|
||||
deprecated: ConfigType,
|
||||
expected: str,
|
||||
named: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _migrate(config)
|
||||
|
||||
assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1}
|
||||
assert f"[test_strip] {named} deprecated" in caplog.text
|
||||
assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text
|
||||
assert "2027.3.0" in caplog.text
|
||||
|
||||
|
||||
def test_migrate_does_not_mutate_input() -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
|
||||
_migrate(config)
|
||||
assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB])
|
||||
def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None:
|
||||
config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"}
|
||||
with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"):
|
||||
_migrate(config)
|
||||
|
||||
|
||||
def test_migrate_reports_every_conflicting_key() -> None:
|
||||
config = {
|
||||
CONF_CHANNEL_COLORS: "GRBW",
|
||||
CONF_RGB_ORDER: "GRB",
|
||||
CONF_IS_RGBW: True,
|
||||
CONF_IS_WRGB: False,
|
||||
}
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'"
|
||||
):
|
||||
_migrate(config)
|
||||
|
||||
|
||||
def test_migrate_requires_channel_colors() -> None:
|
||||
with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"):
|
||||
_migrate({"num_leds": 1})
|
||||
|
||||
|
||||
def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None:
|
||||
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True}
|
||||
with pytest.raises(cv.Invalid, match="cannot both be enabled"):
|
||||
_migrate(config)
|
||||
@@ -53,9 +53,12 @@ def test_nonzero_indices_are_nonzero(gamma: float) -> None:
|
||||
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
|
||||
@pytest.mark.parametrize("gamma", [1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0])
|
||||
def test_table_monotonically_nondecreasing(gamma: float) -> None:
|
||||
"""The gamma table must be monotonically non-decreasing."""
|
||||
"""The gamma table must be monotonically non-decreasing.
|
||||
|
||||
gamma_table_reverse_search()'s binary search depends on this.
|
||||
"""
|
||||
table = generate_gamma_table(gamma)
|
||||
for i in range(1, 256):
|
||||
assert table[i] >= table[i - 1], (
|
||||
@@ -115,3 +118,13 @@ def test_lut_output_monotonically_nondecreasing() -> None:
|
||||
result = _simulate_gamma_correct_lut(table, value)
|
||||
assert result >= prev, f"value={value}: result {result} < previous {prev}"
|
||||
prev = result
|
||||
|
||||
|
||||
def test_table_matches_raw_power_curve() -> None:
|
||||
"""Check the gamma table against known good values for gamma=2.8."""
|
||||
table = generate_gamma_table(2.8)
|
||||
golden = {1: 1, 5: 1, 15: 24, 27: 122, 28: 135, 100: 4766, 200: 33193, 254: 64818}
|
||||
for i, expected in golden.items():
|
||||
assert table[i] == expected, (
|
||||
f"index {i}: table[{i}]={table[i]} expected {expected}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for the LVGL table widget's C++ code generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.defines import set_widgets_completed
|
||||
from esphome.components.lvgl.lvcode import LvContext
|
||||
from esphome.components.lvgl.schemas import container_schema
|
||||
from esphome.components.lvgl.trigger import generate_triggers
|
||||
from esphome.components.lvgl.widgets import Widget, widget_to_code
|
||||
from esphome.components.lvgl.widgets.table import table_spec
|
||||
from esphome.const import (
|
||||
CONF_AUTOMATION_ID,
|
||||
CONF_ON_VALUE,
|
||||
CONF_THEN,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArguments
|
||||
from esphome.yaml_util import make_data_base
|
||||
|
||||
|
||||
async def _create_table(raw_config: dict) -> Widget:
|
||||
"""Validate `raw_config` as a table widget and generate its creation code."""
|
||||
config = container_schema(table_spec)(raw_config)
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
return await widget_to_code(config, table_spec, parent)
|
||||
|
||||
|
||||
def _statements() -> list[str]:
|
||||
return [str(s) for s in CORE.main_statements]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_row_and_column_count(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements)
|
||||
assert any(
|
||||
"lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_writes_cell_values(setup_core) -> None:
|
||||
await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_cell_control_flags(setup_core) -> None:
|
||||
await _create_table(
|
||||
{
|
||||
"id": "table_ctrl",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "wide", "merge_right": True},
|
||||
{"text": "cropped", "text_crop": True},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
# text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted.
|
||||
assert not any(
|
||||
"table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None:
|
||||
await _create_table({"id": "table_px", "columns": [{"width": 96}]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None:
|
||||
"""Regression test: lv_table_set_column_width() only accepts a literal
|
||||
pixel count, so a percentage width must not be passed to it directly -
|
||||
it has to go through the LvTableType helper that recomputes it at
|
||||
runtime from the table's actual content width.
|
||||
"""
|
||||
await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]})
|
||||
statements = _statements()
|
||||
assert any("table_pct->init_column_pct(1)" in s for s in statements)
|
||||
assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements)
|
||||
assert not any(
|
||||
"lv_table_set_column_width(table_pct->obj, 0" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_both_indices(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_sel_both", "selected_row": 1, "selected_column": 2}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None:
|
||||
await _create_table({"id": "table_sel_row", "selected_row": 1})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_omitted_entirely_when_not_configured(
|
||||
setup_core,
|
||||
) -> None:
|
||||
await _create_table({"id": "table_no_selection", "rows": [["a"]]})
|
||||
statements = _statements()
|
||||
assert not any("lv_table_set_selected_cell" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
|
||||
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
|
||||
set_widgets_completed(True)
|
||||
# Only inspect statements emitted by the action below, not by creation.
|
||||
before = len(_statements())
|
||||
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "table_update", "row": 1, "column": 1, "text": "new value"}
|
||||
)
|
||||
action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id)
|
||||
await entry.coroutine_fun(config, action_id, TemplateArguments(), [])
|
||||
|
||||
statements = _statements()[before:]
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s
|
||||
for s in statements
|
||||
)
|
||||
# Neither control flag was specified, so neither call should be emitted.
|
||||
assert not any("LV_TABLE_CELL_CTRL" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None:
|
||||
config = container_schema(table_spec)(
|
||||
{
|
||||
"id": "table_on_value",
|
||||
"rows": [["a"]],
|
||||
"on_value": [
|
||||
{"lambda": make_data_base("id(table_on_value).get_selected_row();")}
|
||||
],
|
||||
}
|
||||
)
|
||||
# Auto-generated IDs (trigger/automation/action) are normally resolved to
|
||||
# unique names by esphome's full config pass before code generation; do
|
||||
# that by hand here since this test only exercises the widget/trigger
|
||||
# codegen slice in isolation.
|
||||
automation_conf = config[CONF_ON_VALUE][0]
|
||||
automation_conf[CONF_TRIGGER_ID].resolve([])
|
||||
automation_conf[CONF_AUTOMATION_ID].resolve([])
|
||||
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
|
||||
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
await widget_to_code(config, table_spec, parent)
|
||||
set_widgets_completed(True)
|
||||
await generate_triggers()
|
||||
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"table_on_value->obj" in s
|
||||
and "add_event_cb" in s
|
||||
and "LV_EVENT_VALUE_CHANGED" in s
|
||||
for s in statements
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the LVGL table widget's configuration validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.widgets.table import (
|
||||
CONF_MERGE_RIGHT,
|
||||
CONF_TEXT_CROP,
|
||||
TABLE_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_config_is_valid() -> None:
|
||||
assert TABLE_SCHEMA({}) == {}
|
||||
|
||||
|
||||
def test_row_shorthand_expands_to_plain_cells() -> None:
|
||||
config = TABLE_SCHEMA({"rows": [["Name", "Value"]]})
|
||||
[row] = config["rows"]
|
||||
assert row["cells"] == [{"text": "Name"}, {"text": "Value"}]
|
||||
|
||||
|
||||
def test_row_dict_form_with_cell_overrides() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"Temp",
|
||||
{"text": "22.5", "text_crop": True, "merge_right": True},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
[row] = config["rows"]
|
||||
assert row["cells"][0] == {"text": "Temp"}
|
||||
assert row["cells"][1] == {
|
||||
"text": "22.5",
|
||||
"merge_right": True,
|
||||
"text_crop": True,
|
||||
}
|
||||
|
||||
|
||||
def test_row_count_defaults_are_not_injected_by_the_schema() -> None:
|
||||
# Inference of row/column counts from `rows` happens at code generation
|
||||
# time, not during validation - the schema should leave them unset.
|
||||
config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]})
|
||||
assert "row_count" not in config
|
||||
assert "column_count" not in config
|
||||
|
||||
|
||||
def test_explicit_row_and_column_count_are_kept() -> None:
|
||||
config = TABLE_SCHEMA({"row_count": 5, "column_count": 3})
|
||||
assert config["row_count"] == 5
|
||||
assert config["column_count"] == 3
|
||||
|
||||
|
||||
def test_row_count_too_small_for_given_rows_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="row_count"):
|
||||
TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2})
|
||||
|
||||
|
||||
def test_column_count_too_small_for_given_cells_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="column_count"):
|
||||
TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2})
|
||||
|
||||
|
||||
def test_columns_list_longer_than_column_count_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA(
|
||||
{
|
||||
"column_count": 1,
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_columns_list_matching_inferred_column_count_is_valid() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [["a", "b"]],
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [10, 20]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "expected"),
|
||||
[
|
||||
(100, 100),
|
||||
("50%", 0.5),
|
||||
("32px", 32),
|
||||
],
|
||||
)
|
||||
def test_column_width_accepts_pixels_and_percent(width, expected) -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": width}]})
|
||||
assert config["columns"][0]["width"] == expected
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_over_100_percent_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]})
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]})
|
||||
assert [c["width"] for c in config["columns"]] == [0.6, 0.4]
|
||||
|
||||
|
||||
def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None:
|
||||
# Pixel widths aren't part of the percentage budget, so they shouldn't
|
||||
# count towards the 100% limit.
|
||||
config = TABLE_SCHEMA(
|
||||
{"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2]
|
||||
|
||||
|
||||
def test_selected_row_and_selected_column_are_independently_optional() -> None:
|
||||
config = TABLE_SCHEMA({"selected_row": 1})
|
||||
assert config["selected_row"] == 1
|
||||
assert "selected_column" not in config
|
||||
|
||||
|
||||
def test_cell_update_action_requires_at_least_one_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
with pytest.raises(cv.Invalid):
|
||||
entry.schema({"id": "some_table", "row": 0, "column": 0})
|
||||
|
||||
|
||||
def test_cell_update_action_accepts_a_single_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "some_table", "row": 0, "column": 0, "merge_right": True}
|
||||
)
|
||||
assert config[CONF_MERGE_RIGHT] is True
|
||||
assert CONF_TEXT_CROP not in config
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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,
|
||||
)
|
||||
from esphome.external_files import RemoteFile
|
||||
|
||||
|
||||
@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 == [
|
||||
RemoteFile(
|
||||
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 == [
|
||||
RemoteFile(
|
||||
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
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for the shelly_dimmer firmware download and prefetch extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import external_files
|
||||
from esphome.components.shelly_dimmer import light as shd
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.external_files import RemoteFile
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def test_prefetch_known_version(setup_core: Path) -> None:
|
||||
entries = [{"firmware": {"version": "51.6", "update": True}}]
|
||||
stages = list(shd.PREFETCH_FILES(entries))
|
||||
url, sha = shd.KNOWN_FIRMWARE["51.6"]
|
||||
assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]]
|
||||
|
||||
|
||||
def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None:
|
||||
"""Quoted booleans behave as the schema will normalize them."""
|
||||
url, sha = shd.KNOWN_FIRMWARE["51.6"]
|
||||
off = [{"firmware": {"version": "51.6", "update": "false"}}]
|
||||
assert list(shd.PREFETCH_FILES(off)) == [[]]
|
||||
on = [{"firmware": {"version": "51.6", "update": "true"}}]
|
||||
assert list(shd.PREFETCH_FILES(on)) == [
|
||||
[RemoteFile(url, shd._firmware_cache_path(sha))]
|
||||
]
|
||||
|
||||
|
||||
def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None:
|
||||
"""A raw sha256 that is not a hash never becomes a path component."""
|
||||
entries = [
|
||||
{
|
||||
"firmware": {
|
||||
"url": "https://example.com/fw.bin",
|
||||
"sha256": "/tmp/payload",
|
||||
"update": True,
|
||||
}
|
||||
}
|
||||
]
|
||||
assert list(shd.PREFETCH_FILES(entries)) == [[]]
|
||||
|
||||
|
||||
def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None:
|
||||
"""A sha-keyed cache file needs no revalidation; get_firmware hashes it."""
|
||||
url, sha = shd.KNOWN_FIRMWARE["51.6"]
|
||||
shd._firmware_cache_path(sha).write_bytes(b"pinned firmware")
|
||||
entries = [{"firmware": {"version": "51.6", "update": True}}]
|
||||
assert list(shd.PREFETCH_FILES(entries)) == [[]]
|
||||
|
||||
|
||||
def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None:
|
||||
url = "https://example.com/fw.bin"
|
||||
entries = [{"firmware": {"url": url, "update": True}}]
|
||||
stages = list(shd.PREFETCH_FILES(entries))
|
||||
key = external_files.url_cache_key(url)
|
||||
# No sha means the bytes cannot be verified, so the prefetch itself
|
||||
# must carry the validator's strict no-stale policy.
|
||||
assert stages == [
|
||||
[RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)]
|
||||
]
|
||||
|
||||
|
||||
def test_prefetch_skips_no_update(setup_core: Path) -> None:
|
||||
entries = [
|
||||
{"firmware": {"version": "51.6"}},
|
||||
{"firmware": "51.6"},
|
||||
{"firmware": {"version": "0.0", "update": True}},
|
||||
{},
|
||||
]
|
||||
assert list(shd.PREFETCH_FILES(entries)) == [[]]
|
||||
|
||||
|
||||
def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None:
|
||||
"""A cached blob failing its hash check is discarded and re-downloaded."""
|
||||
good = b"good firmware"
|
||||
expected = _sha(good)
|
||||
path = shd._firmware_cache_path(expected)
|
||||
path.write_bytes(b"corrupted blob")
|
||||
|
||||
with patch(
|
||||
"esphome.components.shelly_dimmer.light.external_files.download_content",
|
||||
return_value=good,
|
||||
) as mock_download:
|
||||
result = shd.get_firmware(
|
||||
{
|
||||
"update": True,
|
||||
"url": "https://example.com/fw.bin",
|
||||
"sha256": expected,
|
||||
}
|
||||
)
|
||||
|
||||
mock_download.assert_called_once()
|
||||
assert result == [int(b) for b in good]
|
||||
|
||||
|
||||
def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None:
|
||||
"""A cached blob passing its hash check is used with zero network."""
|
||||
good = b"good firmware"
|
||||
expected = _sha(good)
|
||||
shd._firmware_cache_path(expected).write_bytes(good)
|
||||
|
||||
with patch(
|
||||
"esphome.components.shelly_dimmer.light.external_files.download_content"
|
||||
) as mock_download:
|
||||
result = shd.get_firmware(
|
||||
{
|
||||
"update": True,
|
||||
"url": "https://example.com/fw.bin",
|
||||
"sha256": expected,
|
||||
}
|
||||
)
|
||||
|
||||
mock_download.assert_not_called()
|
||||
assert result == [int(b) for b in good]
|
||||
|
||||
|
||||
def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None:
|
||||
"""A fresh download failing its hash check raises and is not cached."""
|
||||
expected = _sha(b"expected firmware")
|
||||
path = shd._firmware_cache_path(expected)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.shelly_dimmer.light.external_files.download_content",
|
||||
return_value=b"wrong firmware",
|
||||
),
|
||||
pytest.raises(Invalid, match="Hash mismatch"),
|
||||
):
|
||||
shd.get_firmware(
|
||||
{"update": True, "url": "https://example.com/fw.bin", "sha256": expected}
|
||||
)
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None:
|
||||
"""The unverifiable no-hash branch must not accept a stale copy."""
|
||||
with patch(
|
||||
"esphome.components.shelly_dimmer.light.external_files.download_content",
|
||||
return_value=b"fw",
|
||||
) as mock_download:
|
||||
shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"})
|
||||
|
||||
assert mock_download.call_args.kwargs["allow_stale"] is False
|
||||
@@ -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,77 @@ 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
|
||||
|
||||
|
||||
def test_process_stacktrace_esp32_foreign_crash(
|
||||
setup_core: Path, mock_esp32_decode_pc: Mock
|
||||
) -> None:
|
||||
"""Crash records from a different firmware build must not be decoded."""
|
||||
from esphome.components.esp32 import process_stacktrace
|
||||
|
||||
config = {"name": "test"}
|
||||
|
||||
line_note = (
|
||||
"[E][esp32.crash:390]: Captured by a different firmware build; "
|
||||
"addresses belong to that build's ELF"
|
||||
)
|
||||
state = process_stacktrace(config, line_note, False)
|
||||
mock_esp32_decode_pc.assert_not_called()
|
||||
assert state is False
|
||||
|
||||
# Lowercase labels are deliberately not matched by any decoder regex,
|
||||
# since symbols would come from the wrong ELF
|
||||
lines_addrs = [
|
||||
"[E][esp32.crash:391]: pc: 0x400D1234",
|
||||
"[E][esp32.crash:392]: excvaddr: 0x400D5678",
|
||||
"[E][esp32.crash:392]: mtval: 0x42001234",
|
||||
"[E][esp32.crash:393]: bt0: 0x400F19A6",
|
||||
"[E][esp32.crash:394]: other core (0):",
|
||||
"[E][esp32.crash:395]: bt15: 0x42005ABC",
|
||||
]
|
||||
for line in lines_addrs:
|
||||
state = process_stacktrace(config, line, False)
|
||||
mock_esp32_decode_pc.assert_not_called()
|
||||
assert state is False
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for LibreTiny board detection, including renamed-board migration."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import bk72xx, ln882x, rtl87xx
|
||||
from esphome.components.libretiny import BASE_SCHEMA, _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, KEY_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"})
|
||||
|
||||
|
||||
def test_platform_schemas_are_isolated_instances() -> None:
|
||||
"""Each LibreTiny platform must own its CONFIG_SCHEMA instance.
|
||||
|
||||
BASE_SCHEMA is shared; every platform prepends its own _set_core_data
|
||||
extra. On the shared object, importing two platform modules in one process
|
||||
made either platform's validation run both extras, so the wrong platform's
|
||||
component data won and known boards failed to resolve.
|
||||
"""
|
||||
platforms = (bk72xx, ln882x, rtl87xx)
|
||||
schemas = [platform.CONFIG_SCHEMA for platform in platforms]
|
||||
assert len({id(schema) for schema in (BASE_SCHEMA, *schemas)}) == 4
|
||||
# The shared base must not have accumulated any platform's extra.
|
||||
# prepend_extra wraps validators in _Schema, so unwrap before comparing.
|
||||
base_extras = [extra.schema for extra in BASE_SCHEMA._extra_schemas]
|
||||
for platform in platforms:
|
||||
assert platform._set_core_data not in base_extras
|
||||
|
||||
|
||||
def test_each_platform_resolves_its_own_boards() -> None:
|
||||
"""Validating one platform's config must leave that platform's component
|
||||
data in CORE.data. On the shared schema, the last-imported platform's
|
||||
_set_core_data won for every platform, so known boards failed to resolve
|
||||
with "This board is unknown"."""
|
||||
CORE.data[KEY_CORE] = {} # written by the schema's _update_core_data extra
|
||||
for platform, board in (
|
||||
(ln882x, "generic-ln882h"),
|
||||
(bk72xx, "generic-bk7252"),
|
||||
(rtl87xx, "generic-rtl8720cf-2mb-896k"),
|
||||
):
|
||||
platform.CONFIG_SCHEMA({CONF_BOARD: board})
|
||||
assert CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] is platform.COMPONENT_DATA
|
||||
@@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered
|
||||
by the framework tests under ``tests/unit_tests/``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.components import rp2
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
|
||||
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
|
||||
|
||||
|
||||
@@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None:
|
||||
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"
|
||||
|
||||
@@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None:
|
||||
|
||||
assert rp2040_boards is rp2_boards
|
||||
assert rp2040_generate is rp2_generate
|
||||
|
||||
|
||||
def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None:
|
||||
"""The segment pool is global while the send queue is per-PCB.
|
||||
|
||||
lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``,
|
||||
which is the floor for a *single* connection: at equality one busy PCB can
|
||||
drain the pool for every other PCB. Dropping back to that floor would
|
||||
rebuild the starvation this sizing exists to prevent, and nothing in the
|
||||
build would complain.
|
||||
"""
|
||||
assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN
|
||||
|
||||
|
||||
def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None:
|
||||
"""``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on
|
||||
``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the
|
||||
heap past that bound is a real option, but it should be a deliberate one
|
||||
rather than a side effect of tuning.
|
||||
"""
|
||||
assert rp2.LWIP_MEM_SIZE <= 64000
|
||||
|
||||
|
||||
def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None:
|
||||
"""Pin the floor as well as the ceiling.
|
||||
|
||||
The ceiling above is satisfied by arduino-pico's own 16 KB, which is the
|
||||
value this change exists to move off, so on its own it would let a revert
|
||||
through. Derive the floor from the sizing comment on the constant: with
|
||||
TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block
|
||||
(pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB),
|
||||
a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's
|
||||
max_connections on rp2 is 4. Room for three concurrent senders is the
|
||||
minimum that makes the change worth making; 16 KB does not reach it.
|
||||
"""
|
||||
segments_per_full_send_buf = 4
|
||||
bytes_per_mss_block = 1536
|
||||
concurrent_senders = 3
|
||||
|
||||
assert (
|
||||
concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block
|
||||
<= rp2.LWIP_MEM_SIZE
|
||||
)
|
||||
|
||||
|
||||
def test_lwip_defines_carry_the_sizing_into_the_header() -> None:
|
||||
"""The constants above only matter if they reach the generated header.
|
||||
|
||||
``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it
|
||||
rather than on the constants alone: dropping a key here would silently
|
||||
fall back to arduino-pico's own value while every other assertion in this
|
||||
file stayed green.
|
||||
"""
|
||||
defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2)
|
||||
|
||||
assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE)
|
||||
assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG)
|
||||
assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN)
|
||||
# Socket-derived counts pass through untouched.
|
||||
assert defines["MEMP_NUM_TCP_PCB"] == "8"
|
||||
assert defines["MEMP_NUM_UDP_PCB"] == "6"
|
||||
assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2"
|
||||
|
||||
|
||||
def test_lwipopts_template_renders_every_sizing_value() -> None:
|
||||
"""Render the template the way _generate_lwipopts_h() does and check the
|
||||
header that actually ships.
|
||||
|
||||
Covers both directions. A ``#define`` block deleted from the template
|
||||
leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB
|
||||
heap this change exists to move off, and the loop below catches that. A
|
||||
placeholder with no dict key would otherwise render empty and emit a bare
|
||||
``#define FOO``; StrictUndefined turns that into an error instead.
|
||||
Matching on text also survives a filter or conditional appearing in the
|
||||
template later, which a placeholder regex would not.
|
||||
"""
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2)
|
||||
template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
rendered = (
|
||||
Environment(keep_trailing_newline=True, undefined=StrictUndefined)
|
||||
.from_string(template_text)
|
||||
.render(**defines)
|
||||
)
|
||||
|
||||
for name, value in defines.items():
|
||||
assert re.search(
|
||||
rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE
|
||||
), f"{name} did not reach the generated header as {value!r}"
|
||||
|
||||
@@ -8,7 +8,11 @@ import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins
|
||||
from esphome.components.rp2.generate_boards import (
|
||||
generate,
|
||||
load_boards,
|
||||
parse_variant_pins,
|
||||
)
|
||||
|
||||
PICO_PINS_HEADER = textwrap.dedent("""\
|
||||
#pragma once
|
||||
@@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None:
|
||||
assert boards["rpipico"]["name"] == "Raspberry Pi Pico"
|
||||
assert boards["rpipico"]["mcu"] == "rp2040"
|
||||
assert boards["rpipico"]["max_pin"] == 29
|
||||
# The die key only applies to the RP2350, which ships as more than one die
|
||||
assert "die" not in boards["rpipico"]
|
||||
|
||||
assert "rpipico" in board_pins
|
||||
assert board_pins["rpipico"]["LED"] == 25
|
||||
@@ -158,19 +164,195 @@ def test_load_basic_board(arduino_pico: Path) -> None:
|
||||
|
||||
|
||||
def test_load_rp2350_board(arduino_pico: Path) -> None:
|
||||
"""The Pico 2 uses the RP2350A die, which only exposes GPIO 0-29."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"rpipico2",
|
||||
mcu="rp2350",
|
||||
vendor="Raspberry Pi",
|
||||
name="Pico 2",
|
||||
pins_header=PICO_PINS_HEADER,
|
||||
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
_, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["rpipico2"]["mcu"] == "rp2350"
|
||||
assert boards["rpipico2"]["max_pin"] == 47
|
||||
assert boards["rpipico2"]["max_pin"] == 29
|
||||
assert boards["rpipico2"]["die"] == "A"
|
||||
|
||||
|
||||
def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None:
|
||||
"""A variant without PICO_RP2350A cannot be classified; fail loudly."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"no_die_define",
|
||||
mcu="rp2350",
|
||||
pins_header=PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="no PICO_RP2350A define"):
|
||||
load_boards(arduino_pico)
|
||||
|
||||
|
||||
def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None:
|
||||
"""An unparseable PICO_RP2350A value must not silently widen to B-die."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"hex_die_define",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 0x1\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unrecognized PICO_RP2350A value"):
|
||||
load_boards(arduino_pico)
|
||||
|
||||
|
||||
def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None:
|
||||
"""A third die breaks the "not A means B" reading, so stop rather than guess."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"future_die",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n"
|
||||
+ PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="found a PICO_RP2350C define"):
|
||||
load_boards(arduino_pico)
|
||||
|
||||
|
||||
def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None:
|
||||
"""PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"revision_define",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n"
|
||||
+ PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
_, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["revision_define"]["die"] == "A"
|
||||
|
||||
|
||||
def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None:
|
||||
"""Literal forms like (1u) classify the same as bare 1."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"paren_die",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A (1u)\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
_, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["paren_die"]["max_pin"] == 29
|
||||
assert boards["paren_die"]["die"] == "A"
|
||||
|
||||
|
||||
def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None:
|
||||
"""A variant declaring the RP2350B die keeps the full GPIO 0-47 range.
|
||||
|
||||
The define uses extra whitespace, matching real variant headers.
|
||||
"""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"weact_rp2350b",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 0 // RP2350B\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
_, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["weact_rp2350b"]["max_pin"] == 47
|
||||
assert boards["weact_rp2350b"]["die"] == "B"
|
||||
|
||||
|
||||
def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None:
|
||||
"""Generic boards leave the die a build-time choice; stay permissive.
|
||||
|
||||
The permissive range is a fallback, so the die must be recorded as unknown
|
||||
rather than as the B die.
|
||||
"""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"generic_rp2350",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
_, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["generic_rp2350"]["max_pin"] == 47
|
||||
assert boards["generic_rp2350"]["die"] is None
|
||||
|
||||
|
||||
def test_generated_output_records_die(arduino_pico: Path) -> None:
|
||||
"""The rendered boards.py carries the die on every RP2350 entry."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"rpipico",
|
||||
pins_header=PICO_PINS_HEADER,
|
||||
)
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"a_die",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"b_die",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"menu_die",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
|
||||
)
|
||||
|
||||
namespace: dict = {}
|
||||
exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace)
|
||||
|
||||
boards = namespace["BOARDS"]
|
||||
assert boards["a_die"]["die"] == "A"
|
||||
assert boards["b_die"]["die"] == "B"
|
||||
assert boards["menu_die"]["die"] is None
|
||||
assert "die" not in boards["rpipico"]
|
||||
|
||||
|
||||
def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None:
|
||||
"""Pin defines beyond the A-die range are dropped from the pin map."""
|
||||
header = textwrap.dedent("""\
|
||||
#define PICO_RP2350A 1
|
||||
#define PIN_LED (25u)
|
||||
#define PIN_SPI0_MISO (40u)
|
||||
""")
|
||||
_add_board(arduino_pico, "a_die", mcu="rp2350", pins_header=header)
|
||||
|
||||
board_pins, _ = load_boards(arduino_pico)
|
||||
|
||||
assert board_pins["a_die"]["LED"] == 25
|
||||
assert "MISO" not in board_pins["a_die"]
|
||||
|
||||
|
||||
def test_rp2350a_board_keeps_cyw43_virtual_pins(arduino_pico: Path) -> None:
|
||||
"""A-die narrowing must not filter CYW43 virtual pins (64-66)."""
|
||||
_add_board(
|
||||
arduino_pico,
|
||||
"rpipico2w",
|
||||
mcu="rp2350",
|
||||
pins_header="#define PICO_RP2350A 1\n" + PICOW_PINS_HEADER,
|
||||
)
|
||||
|
||||
board_pins, boards = load_boards(arduino_pico)
|
||||
|
||||
assert boards["rpipico2w"]["max_pin"] == 29
|
||||
assert boards["rpipico2w"]["max_virtual_pin"] == 64
|
||||
assert board_pins["rpipico2w"]["LED"] == 64
|
||||
|
||||
|
||||
def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ not be part of a unit test suite.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -40,6 +41,19 @@ def fixture_path() -> Path:
|
||||
return here / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe_env() -> dict[str, str]:
|
||||
"""Environment for running fixture probe scripts as subprocesses.
|
||||
|
||||
Running a script file drops the cwd from sys.path, so prepend the
|
||||
repo root for the child.
|
||||
"""
|
||||
python_path = str(package_root)
|
||||
if ambient := os.environ.get("PYTHONPATH"):
|
||||
python_path = os.pathsep.join((python_path, ambient))
|
||||
return os.environ | {"PYTHONPATH": python_path}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_core(tmp_path: Path) -> Path:
|
||||
"""Set up CORE with test paths."""
|
||||
|
||||
@@ -29,5 +29,5 @@ def load_config_from_fixture(
|
||||
) -> Config | None:
|
||||
"""Load configuration from a fixture file."""
|
||||
fixture_path = fixtures_dir / fixture_name
|
||||
yaml_content = fixture_path.read_text()
|
||||
yaml_content = fixture_path.read_text(encoding="utf-8")
|
||||
return load_config_from_yaml(yaml_file, yaml_content)
|
||||
|
||||
@@ -12,7 +12,7 @@ def yaml_file(tmp_path: Path) -> Callable[[str], Path]:
|
||||
|
||||
def _yaml_file(content: str) -> Path:
|
||||
yaml_path = tmp_path / "test.yaml"
|
||||
yaml_path.write_text(content)
|
||||
yaml_path.write_text(content, encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
return _yaml_file
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from pathlib import Path
|
||||
import types
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
@@ -705,33 +704,6 @@ def test_include_file_with_c_header(
|
||||
assert '#include "c_library.h"' in mock_raw_statement.text
|
||||
|
||||
|
||||
def test_get_usable_cpu_count() -> None:
|
||||
"""Test get_usable_cpu_count returns CPU count."""
|
||||
count = config.get_usable_cpu_count()
|
||||
assert isinstance(count, int)
|
||||
assert count > 0
|
||||
|
||||
|
||||
def test_get_usable_cpu_count_with_process_cpu_count() -> None:
|
||||
"""Test get_usable_cpu_count uses process_cpu_count when available."""
|
||||
# Test with process_cpu_count (Python 3.13+)
|
||||
# Create a mock os module with process_cpu_count
|
||||
|
||||
mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4)
|
||||
|
||||
with patch("esphome.core.config.os", mock_os):
|
||||
# When process_cpu_count exists, it should be used
|
||||
count = config.get_usable_cpu_count()
|
||||
assert count == 8
|
||||
|
||||
# Test fallback to cpu_count when process_cpu_count not available
|
||||
mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4)
|
||||
|
||||
with patch("esphome.core.config.os", mock_os_no_process):
|
||||
count = config.get_usable_cpu_count()
|
||||
assert count == 4
|
||||
|
||||
|
||||
def test_list_target_platforms(tmp_path: Path) -> None:
|
||||
"""Test _list_target_platforms returns available platforms."""
|
||||
# Create mock components directory structure
|
||||
@@ -1155,6 +1127,34 @@ def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_config_hash_same_for_different_data_dirs(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that downloaded file paths hash the same wherever data_dir lives."""
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
CORE.reset()
|
||||
CORE.config_path = config_dir / "device.yaml"
|
||||
CORE.config = {
|
||||
"esphome": {"name": "test"},
|
||||
"file": config_dir / ".esphome" / "image" / "c44630d6",
|
||||
}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
other_data_dir = tmp_path / "data"
|
||||
CORE.reset()
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(other_data_dir))
|
||||
CORE.config_path = config_dir / "device.yaml"
|
||||
CORE.config = {
|
||||
"esphome": {"name": "test"},
|
||||
"file": other_data_dir / "image" / "c44630d6",
|
||||
}
|
||||
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(
|
||||
@@ -1242,6 +1242,15 @@ def test_make_app_name_cpp_special_chars_escaped() -> None:
|
||||
None,
|
||||
"https://github.com/esphome/noise-c.git",
|
||||
),
|
||||
# A local file:// source is routed to the repository, not a registry name
|
||||
# -- including the fewer-than-two-slashes spelling.
|
||||
(
|
||||
"TeslaBLE=file:///config/esphome/lib_dev",
|
||||
"TeslaBLE",
|
||||
None,
|
||||
"file:///config/esphome/lib_dev",
|
||||
),
|
||||
("MyLib=file:lib_dev", "MyLib", None, "file:lib_dev"),
|
||||
],
|
||||
)
|
||||
def test_add_library_str(
|
||||
@@ -1276,6 +1285,7 @@ async def test_add_platformio_options_native_idf(
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
|
||||
"build_unflags": ["-Os"],
|
||||
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
|
||||
"lib_ignore": "libsodium",
|
||||
"upload_speed": "115200",
|
||||
@@ -1285,6 +1295,7 @@ async def test_add_platformio_options_native_idf(
|
||||
|
||||
assert "-DSINGLE_FLAG" in CORE.build_flags
|
||||
assert "ArduinoJson" in CORE.platformio_libraries
|
||||
assert "-Os" in CORE.build_unflags
|
||||
# lib_ignore is stored (listified) for generate_idf_components to read;
|
||||
# nothing else lands in platformio_options on the native toolchain.
|
||||
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
|
||||
@@ -1380,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None:
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_platformio_options_native_arduino(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The native ESP8266 Arduino toolchain honors board_build.f_cpu (a
|
||||
real-world overclock knob) and warns about the rest like native IDF."""
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp8266",
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"board_build.f_cpu": "160000000L",
|
||||
# The schema also permits the list form; the last value wins
|
||||
# and reaches the generator as a scalar
|
||||
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
|
||||
"board_build.filesystem": "littlefs",
|
||||
"upload_speed": "115200",
|
||||
}
|
||||
)
|
||||
|
||||
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
|
||||
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert (
|
||||
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
)
|
||||
# An empty list for an honored key is not a scalar; it falls through
|
||||
# to the ignored-option warning instead of an IndexError
|
||||
await config._add_platformio_options({"board_build.ldscript": []})
|
||||
assert "board_build.ldscript is ignored" in caplog.text
|
||||
assert "'arduino' toolchain" in caplog.text
|
||||
assert "upload_speed" not in caplog.text
|
||||
|
||||
|
||||
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
|
||||
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
|
||||
from esphome.components.esp8266 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"board": "nodemcuv2"})
|
||||
|
||||
@@ -34,7 +34,7 @@ from esphome.core.entity_helpers import (
|
||||
setup_unit_of_measurement,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.helpers import sanitize, snake_case
|
||||
from esphome.helpers import fnv1_hash, sanitize, snake_case
|
||||
|
||||
from .common import load_config_from_fixture
|
||||
|
||||
@@ -515,9 +515,9 @@ def test_entity_duplicate_validator() -> None:
|
||||
config1 = {CONF_NAME: "Temperature"}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
assert ("", "sensor", "temperature") in CORE.unique_ids
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
# Check metadata was stored
|
||||
metadata = CORE.unique_ids[("", "sensor", "temperature")]
|
||||
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata["name"] == "Temperature"
|
||||
assert metadata["platform"] == "sensor"
|
||||
|
||||
@@ -525,8 +525,8 @@ def test_entity_duplicate_validator() -> None:
|
||||
config2 = {CONF_NAME: "Humidity"}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("", "sensor", "humidity") in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("", "sensor", "humidity")]
|
||||
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
|
||||
assert metadata2["name"] == "Humidity"
|
||||
|
||||
# Duplicate entity should fail
|
||||
@@ -537,6 +537,34 @@ def test_entity_duplicate_validator() -> None:
|
||||
validator(config3)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_hash_collision() -> None:
|
||||
"""Test that two different object_ids with the same FNV-1 hash are rejected."""
|
||||
# Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4
|
||||
name_a = "Sensor aooxzi"
|
||||
name_b = "Sensor baraia"
|
||||
object_id_a = sanitize(snake_case(name_a))
|
||||
object_id_b = sanitize(snake_case(name_b))
|
||||
assert object_id_a != object_id_b
|
||||
assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b)
|
||||
|
||||
validator = entity_duplicate_validator("sensor")
|
||||
|
||||
config1 = {CONF_NAME: name_a}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
|
||||
config2 = {CONF_NAME: name_b}
|
||||
with pytest.raises(
|
||||
Invalid,
|
||||
match=re.compile(
|
||||
r"Duplicate sensor entity with name 'Sensor baraia' found.*"
|
||||
r"produce the same entity key hash \(0xe95747e4\)",
|
||||
re.DOTALL,
|
||||
),
|
||||
):
|
||||
validator(config2)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator_with_devices() -> None:
|
||||
"""Test entity_duplicate_validator with devices."""
|
||||
# Create validator for sensor platform
|
||||
@@ -550,15 +578,15 @@ def test_entity_duplicate_validator_with_devices() -> None:
|
||||
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
assert ("device1", "sensor", "temperature") in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")]
|
||||
assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata1["device_id"] == "device1"
|
||||
|
||||
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("device2", "sensor", "temperature") in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")]
|
||||
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
|
||||
assert metadata2["device_id"] == "device2"
|
||||
|
||||
# Duplicate on same device should fail
|
||||
@@ -668,7 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
# New format includes device_id (empty string for main device)
|
||||
assert ("", "sensor", "temperature") in CORE.unique_ids
|
||||
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||
|
||||
# Internal entity with same name should pass (not added to unique_ids)
|
||||
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
|
||||
@@ -676,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
assert validated2 == config2
|
||||
# Internal entity should not be added to unique_ids
|
||||
# Count how many times the key appears (should still be 1)
|
||||
count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature"))
|
||||
count = sum(
|
||||
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Another internal entity with same name should also pass
|
||||
@@ -684,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
||||
validated3 = validator(config3)
|
||||
assert validated3 == config3
|
||||
# Still only one entry in unique_ids (from the non-internal entity)
|
||||
count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature"))
|
||||
count = sum(
|
||||
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Non-internal entity with same name should fail
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Leave a partial line behind and then close the stream under the runner.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining
|
||||
cannot work here; the point is that the failure is reported rather than
|
||||
raised out of the runner's cleanup, where it would bury the exit code.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write("partial before close")
|
||||
sys.stdout.close()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Die part way through a line, the way a build that blows up does.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
|
||||
message has no trailing newline, so the runner's shim is holding it when
|
||||
the process exits; nothing else will ever come to release it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write("FATAL: ld returned 1 exit status")
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Write a mix of noisy and useful build lines, without flushing.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
|
||||
runner's shim owns both the filtering and the flushing, so this script
|
||||
only writes.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write("Project build complete.\n")
|
||||
sys.stdout.write("Compiling main.cpp\n")
|
||||
sys.stdout.write("-- Component paths: /a /b /c\n")
|
||||
sys.stdout.write("[2/9] Building C object\n")
|
||||
# No terminator, so the shim has to hold this one back.
|
||||
sys.stdout.write("still going")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Write a form feed part way through the output.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form
|
||||
feed is not a line terminator here, so everything written must still come
|
||||
out, including the complete lines that follow it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write("Compiling main.cpp\n")
|
||||
sys.stdout.write("page one\x0cpage two\n")
|
||||
sys.stdout.write("[2/9] Building C object\n")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""End on an unterminated line that the filter is supposed to drop.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to
|
||||
check that releasing a held-back line still applies the filter.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write("Compiling main.cpp\n")
|
||||
sys.stdout.write("Project build complete.")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Print one line, then stay alive so the caller can prove it streamed.
|
||||
|
||||
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
|
||||
runner wraps stdout in its filtering shim, so this script deliberately
|
||||
does not flush: the shim has to do it. The long sleep keeps the process
|
||||
running, so anything the caller reads must have arrived while the build
|
||||
was still going rather than at exit.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.stdout.write("Compiling main.cpp\n")
|
||||
time.sleep(60)
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared tail for the lazy-import fixture scripts."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def print_leaked_modules() -> None:
|
||||
"""Report argv-listed heavy modules (plus any component package) loaded.
|
||||
|
||||
Any component package counts as a leak, not just the ones on the
|
||||
watch list: executing one drags in codegen/validation machinery by
|
||||
design.
|
||||
"""
|
||||
leaked = [module for module in sys.argv[1:] if module in sys.modules]
|
||||
leaked += [
|
||||
module
|
||||
for module in sys.modules
|
||||
if module.startswith("esphome.components.") and module not in leaked
|
||||
]
|
||||
print(",".join(leaked))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Shared storage-sidecar factory for the lazy-import fixture scripts."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
|
||||
def make_storage() -> StorageJSON:
|
||||
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts.
|
||||
|
||||
build_path must be set: the fast path rejects sidecars without one.
|
||||
"""
|
||||
return StorageJSON(
|
||||
storage_version=1,
|
||||
name="test",
|
||||
friendly_name="Test",
|
||||
comment=None,
|
||||
esphome_version="2026.1.0",
|
||||
src_version=1,
|
||||
address="1.2.3.4",
|
||||
web_port=None,
|
||||
target_platform="ESP32S3",
|
||||
build_path=Path("/build/test"),
|
||||
firmware_bin_path=Path("/build/test/firmware.bin"),
|
||||
loaded_integrations=set(),
|
||||
loaded_platforms=set(),
|
||||
no_mdns=False,
|
||||
framework="esp-idf",
|
||||
core_platform="esp32",
|
||||
area=None,
|
||||
framework_version="5.3.1",
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Run the esptool serial-upload path and report which heavy modules loaded.
|
||||
|
||||
Executed as a subprocess by test_lazy_imports.py: heavy module names come
|
||||
in on argv, the ones found in sys.modules afterwards go out on stdout.
|
||||
The variant reaches the esptool command line from CORE.data directly; if
|
||||
someone re-adds the esp32 package import for it, this reports the leak.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from _leak_report import print_leaked_modules
|
||||
|
||||
from esphome.__main__ import upload_using_esptool
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
# An ambient ESPHOME_USE_SUBPROCESS would route past the patched
|
||||
# run_external_command into run_external_process and confuse the checks.
|
||||
os.environ.pop("ESPHOME_USE_SUBPROCESS", None)
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
|
||||
|
||||
with patch("esphome.__main__.run_external_command", return_value=0) as mock_run:
|
||||
rc = upload_using_esptool(
|
||||
{CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", "firmware.bin", None
|
||||
)
|
||||
|
||||
# Fail loudly if the upload path stopped doing its work; otherwise an
|
||||
# empty leak list could just mean nothing ran.
|
||||
if rc != 0:
|
||||
sys.exit(f"upload_using_esptool returned {rc}")
|
||||
cmd = list(mock_run.call_args[0][1:])
|
||||
if cmd[cmd.index("--chip") + 1] != "esp32s3":
|
||||
sys.exit(f"variant did not reach the esptool command line: {cmd}")
|
||||
|
||||
print_leaked_modules()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Run the esp32 storage fast path and report which heavy modules loaded.
|
||||
|
||||
Executed as a subprocess by test_lazy_imports.py: heavy module names come
|
||||
in on argv, the ones found in sys.modules afterwards go out on stdout.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from _leak_report import print_leaked_modules
|
||||
from _storage import make_storage
|
||||
|
||||
from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
|
||||
from esphome.core import CORE, Version
|
||||
|
||||
make_storage().apply_to_core()
|
||||
|
||||
# Fail loudly if the esp32 fast path stopped doing its work; otherwise an
|
||||
# empty leak list could just mean nothing ran. Explicit exits rather than
|
||||
# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them.
|
||||
esp32_data = CORE.data.get(KEY_ESP32, {})
|
||||
if esp32_data.get(KEY_VARIANT) != "ESP32S3":
|
||||
sys.exit(f"apply_to_core did not record the variant: {esp32_data!r}")
|
||||
if esp32_data.get(KEY_IDF_VERSION) != Version(5, 3, 1):
|
||||
sys.exit(f"apply_to_core did not parse the framework version: {esp32_data!r}")
|
||||
|
||||
print_leaked_modules()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Run the upload command dispatch path and report which heavy modules loaded.
|
||||
|
||||
Executed as a subprocess by test_lazy_imports.py: heavy module names come
|
||||
in on argv, the ones found in sys.modules afterwards go out on stdout.
|
||||
Covers three fast-path claims: the bundle suffix check in run_esphome reads
|
||||
BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the
|
||||
validated-config cache parse stays voluptuous free, and the JSON cache
|
||||
(lambda sentinel included) resolves without pyyaml or esphome.yaml_util.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from _leak_report import print_leaked_modules
|
||||
from _storage import make_storage
|
||||
|
||||
# Everything imported past this point is the code under test; the pop
|
||||
# below must only drop what the setup itself preloaded, or it would
|
||||
# hide modules the dispatch chain pulls in (tarfile has no other guard).
|
||||
_FIXTURE_PRELOADED = frozenset(sys.modules)
|
||||
|
||||
from esphome import __main__ as main_mod # noqa: E402
|
||||
from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402
|
||||
|
||||
CONFIG_TEXT = "esphome:\n name: t\n"
|
||||
LAMBDA_BODY = 'ESP_LOGD("t", "x");'
|
||||
|
||||
# An ambient data-dir override would relocate the storage tree away
|
||||
# from the tmp config dir this fixture builds.
|
||||
os.environ.pop("ESPHOME_DATA_DIR", None)
|
||||
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
|
||||
|
||||
with tempfile.TemporaryDirectory() as _td:
|
||||
tmp = Path(_td)
|
||||
conf_path = tmp / "test.yaml"
|
||||
conf_path.write_text(CONFIG_TEXT)
|
||||
|
||||
storage_dir = tmp / ".esphome" / "storage"
|
||||
storage_dir.mkdir(parents=True)
|
||||
# The cache carries a lambda sentinel so loading revives a real Lambda
|
||||
# on the fast path. The sidecar is written to the layout
|
||||
# ext_storage_path resolves once run_esphome sets CORE.config_path;
|
||||
# going through CORE here would be circular.
|
||||
cache_path = storage_dir / "test.yaml.validated.json"
|
||||
cache_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"v": 1,
|
||||
"esphome": ESPHOME_VERSION,
|
||||
"config": {
|
||||
"esphome": {"name": "t"},
|
||||
"script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache_path) # keep the cache at least as fresh as the source
|
||||
make_storage().save(storage_dir / "test.yaml.json")
|
||||
|
||||
dispatched = {}
|
||||
|
||||
def fake_upload(args, config):
|
||||
dispatched["config"] = config
|
||||
return 0
|
||||
|
||||
# This setup pre-imports some watched stdlib modules (tempfile above,
|
||||
# write_file inside make_storage().save(), unittest.mock -> asyncio ->
|
||||
# subprocess). Drop exactly those so only a genuine dispatch-time
|
||||
# re-import is reported; live objects keep their references, so
|
||||
# cleanup still works. Module-level re-imports are out of reach here
|
||||
# (esphome.__main__ is already loaded) — the bare-import check in
|
||||
# test_lazy_imports owns that contract.
|
||||
for module in sys.argv[1:]:
|
||||
if module in _FIXTURE_PRELOADED:
|
||||
sys.modules.pop(module, None)
|
||||
|
||||
with patch.dict(main_mod.POST_CONFIG_ACTIONS, {"upload": fake_upload}):
|
||||
exit_code = main_mod.run_esphome(
|
||||
["esphome", "upload", str(conf_path), "--device", "192.0.2.1"]
|
||||
)
|
||||
|
||||
# Fail loudly if the fast path didn't do its work; otherwise an empty
|
||||
# leak list could just mean nothing ran. Explicit exits rather than
|
||||
# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them.
|
||||
if exit_code != 0:
|
||||
sys.exit(f"run_esphome exited {exit_code} before dispatching upload")
|
||||
config = dispatched.get("config")
|
||||
if config is None or config.get("esphome") != {"name": "t"}:
|
||||
sys.exit(f"cache did not resolve through the fast path: {dispatched!r}")
|
||||
from esphome.core import Lambda
|
||||
|
||||
revived = config["script"][0]["lambda"]
|
||||
if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY:
|
||||
sys.exit(f"lambda sentinel did not revive: {revived!r}")
|
||||
|
||||
print_leaked_modules()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Report whether setup_log() pulled in colorama, then print a colored line.
|
||||
|
||||
Executed as a subprocess by test_log.py because module imports are
|
||||
process-global: the parent prints ``colorama_loaded=True/False`` plus an
|
||||
ANSI colored line so the caller can observe whether the codes survive to
|
||||
the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.log import setup_log
|
||||
|
||||
if "--dashboard" in sys.argv:
|
||||
CORE.dashboard = True
|
||||
|
||||
setup_log()
|
||||
|
||||
print(f"colorama_loaded={'colorama' in sys.modules}")
|
||||
print("\033[31mred\033[0m end")
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Tests for esphome.api_client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import api_client
|
||||
from esphome.const import (
|
||||
CONF_ENCRYPTION,
|
||||
CONF_KEY,
|
||||
CONF_PORT,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_component_shim_reexports_runtime_client() -> None:
|
||||
"""The old import paths must keep working for external code."""
|
||||
from esphome.components import api
|
||||
from esphome.components.api import client as shim
|
||||
|
||||
assert shim.run_logs is api_client.run_logs
|
||||
assert shim.async_run_logs is api_client.async_run_logs
|
||||
assert api.CONF_ENCRYPTION is CONF_ENCRYPTION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_full_flow(caplog) -> None:
|
||||
"""Drive async_run_logs end to end with a fake connection.
|
||||
|
||||
Covers the encryption key extraction, the multi-address banner, the
|
||||
registry-miss unavailable notice at session start, the on_log
|
||||
handler, and the stop() cleanup in the finally block.
|
||||
"""
|
||||
caplog.set_level("INFO", logger="esphome.api_client")
|
||||
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"}
|
||||
config = {
|
||||
"esphome": {"name": "test"},
|
||||
"api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}},
|
||||
}
|
||||
|
||||
stop = AsyncMock()
|
||||
run_started = asyncio.Event()
|
||||
|
||||
async def fake_async_run(*args, **kwargs):
|
||||
run_started.set()
|
||||
return stop
|
||||
|
||||
mock_run = AsyncMock(side_effect=fake_async_run)
|
||||
printed: list[str] = []
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", mock_run),
|
||||
patch.object(api_client, "APIClient", autospec=True) as mock_client,
|
||||
patch.object(api_client, "safe_print", printed.append),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"])
|
||||
)
|
||||
# Let the task run up to the forever-wait; the timeout fails the
|
||||
# test instead of hanging it if the task dies early.
|
||||
async with asyncio.timeout(1):
|
||||
await run_started.wait()
|
||||
on_log = mock_run.call_args.args[1]
|
||||
on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960"))
|
||||
# Cancellation is the real termination path; stop() must still run.
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# Both addresses reach APIClient, along with the noise key.
|
||||
assert mock_client.call_args.kwargs["noise_psk"] == "psk123"
|
||||
assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"]
|
||||
assert "1.2.3.4 or 5.6.7.8" in caplog.text
|
||||
# host has no stacktrace analyzer; the notice fires at session start.
|
||||
assert "Stacktrace analysis is unavailable" in caplog.text
|
||||
# The log message was printed with a timestamp prefix.
|
||||
assert any("hello world" in line for line in printed)
|
||||
# stop() ran in the finally block despite the cancellation.
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_never_resolves_without_crash_lines() -> None:
|
||||
"""The headline claim: an ordinary session imports no platform code."""
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
run_started = asyncio.Event()
|
||||
|
||||
async def fake_async_run(*args, **kwargs):
|
||||
run_started.set()
|
||||
return stop
|
||||
|
||||
mock_run = AsyncMock(side_effect=fake_async_run)
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", mock_run),
|
||||
patch.object(api_client, "APIClient"),
|
||||
patch.object(api_client, "safe_print"),
|
||||
patch("esphome.platform_hooks.get_stacktrace_handler") as mock_resolve,
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"])
|
||||
)
|
||||
async with asyncio.timeout(1):
|
||||
await run_started.wait()
|
||||
on_log = mock_run.call_args.args[1]
|
||||
on_log(Mock(message=b"[I][app:100] hello\n[C][wifi:200] connected"))
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
|
||||
def test_run_logs_suppresses_keyboard_interrupt() -> None:
|
||||
"""Ctrl-C during log streaming exits cleanly instead of tracebacking."""
|
||||
with patch.object(
|
||||
api_client,
|
||||
"async_run_logs",
|
||||
AsyncMock(side_effect=KeyboardInterrupt),
|
||||
) as mock_run:
|
||||
api_client.run_logs(
|
||||
{"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False
|
||||
)
|
||||
|
||||
assert mock_run.call_args.kwargs["subscribe_states"] is False
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
That flag is the only thing capping reconnect backoff for a device
|
||||
that is only briefly awake; dropping it means missed wake windows.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None:
|
||||
"""Addresses discovered via MQTT are fed into the running client."""
|
||||
caplog.set_level("INFO", logger="esphome.api_client")
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
fed = asyncio.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
return ["10.0.0.9", "10.0.0.10"]
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True) as mock_client,
|
||||
):
|
||||
mock_client.return_value.add_addresses.side_effect = lambda addrs: (
|
||||
fed.set() or True
|
||||
)
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
async with asyncio.timeout(1):
|
||||
await fed.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
mock_client.return_value.add_addresses.assert_called_once_with(
|
||||
["10.0.0.9", "10.0.0.10"]
|
||||
)
|
||||
assert "Discovered address(es) via MQTT" in caplog.text
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None:
|
||||
"""A resolver returning nothing (failed lookup) leaves the session running."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
resolver_ran = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
# The resolver owns failure handling; a failed lookup returns []
|
||||
resolver_ran.set()
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True) as mock_client,
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_ran.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
mock_client.return_value.add_addresses.assert_not_called()
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None:
|
||||
"""Teardown sets the resolver's stop event so the thread exits promptly."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
captured_event: threading.Event | None = None
|
||||
resolver_started = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
nonlocal captured_event
|
||||
captured_event = stop_event
|
||||
resolver_started.set()
|
||||
# Simulate a slow broker lookup that only ends via the stop event.
|
||||
stop_event.wait(timeout=5)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_started.wait, 1)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.is_set()
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None:
|
||||
"""A resolver raising unexpectedly must not skip stop() at teardown."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
resolver_ran = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
resolver_ran.set()
|
||||
raise RuntimeError("resolver blew up")
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_ran.wait, 1)
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert "MQTT address discovery failed" in caplog.text
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None:
|
||||
"""A successful connection stops the in-flight broker lookup."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
captured_event: threading.Event | None = None
|
||||
resolver_started = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
nonlocal captured_event
|
||||
captured_event = stop_event
|
||||
resolver_started.set()
|
||||
stop_event.wait(timeout=5)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run,
|
||||
patch.object(api_client, "APIClient", autospec=True) as mock_client,
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_started.wait, 1)
|
||||
|
||||
# The runner reports a successful connection
|
||||
on_connect = mock_run.call_args.kwargs["on_connect"]
|
||||
on_connect()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.is_set()
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
mock_client.return_value.add_addresses.assert_not_called()
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None:
|
||||
"""A connection during async_run startup prevents the lookup from starting."""
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
resolver = Mock(name="resolver")
|
||||
|
||||
async def fake_async_run(*args, **kwargs):
|
||||
# Connection succeeds before async_run even returns
|
||||
kwargs["on_connect"]()
|
||||
return stop
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)),
|
||||
patch.object(api_client, "APIClient", autospec=True),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
resolver.assert_not_called()
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None:
|
||||
"""A discovery the client rejects as already known leaves a debug trace."""
|
||||
import threading
|
||||
|
||||
caplog.set_level("DEBUG", logger="esphome.api_client")
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
fed = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
return ["1.2.3.4"]
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True) as mock_client,
|
||||
):
|
||||
mock_client.return_value.add_addresses.side_effect = lambda addrs: (
|
||||
fed.set() or False
|
||||
)
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(fed.wait, 1)
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"])
|
||||
assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text
|
||||
assert "Discovered address(es) via MQTT" not in caplog.text
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None:
|
||||
"""A BaseException escaping the worker is reported, and stop() still runs."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
resolver_ran = threading.Event()
|
||||
|
||||
class WorkerEscape(BaseException):
|
||||
"""Not an Exception, so the task-level guard must not catch it."""
|
||||
|
||||
def resolver(stop_event):
|
||||
resolver_ran.set()
|
||||
raise WorkerEscape("worker bailed")
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_ran.wait, 1)
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert "MQTT address discovery failed" in caplog.text
|
||||
stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None:
|
||||
"""A worker that ignores the stop event is cancelled after the grace period."""
|
||||
import threading
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
||||
|
||||
stop = AsyncMock()
|
||||
resolver_ran = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def resolver(stop_event):
|
||||
resolver_ran.set()
|
||||
# Ignore stop_event entirely; only the test releases us
|
||||
release.wait(timeout=10)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
|
||||
patch.object(api_client, "APIClient", autospec=True),
|
||||
):
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
|
||||
)
|
||||
await asyncio.to_thread(resolver_ran.wait, 1)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
release.set()
|
||||
|
||||
stop.assert_awaited_once()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for esphome.arduino8266.framework (downloads and environment)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_path(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
|
||||
|
||||
def test_framework_package_version() -> None:
|
||||
assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0"
|
||||
assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0"
|
||||
# 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path)
|
||||
assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0"
|
||||
# A future major bump needs its own encoding, not a doomed registry lookup
|
||||
with pytest.raises(EsphomeError, match="not supported yet"):
|
||||
framework.framework_package_version(cv.Version(4, 0, 0))
|
||||
# The boundary matches the PlatformIO era guard; a 2.6.2 pre-release
|
||||
# keeps this encoding
|
||||
with pytest.raises(EsphomeError, match="older package encoding"):
|
||||
framework.framework_package_version(cv.Version(2, 6, 2))
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0"
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0"
|
||||
|
||||
|
||||
def test_format_framework_arduino_version_pins_all_series() -> None:
|
||||
"""The esp8266 component's PIO source formatter across every encoding
|
||||
era, including the 4.x rejection it now shares with the installer."""
|
||||
from esphome.components.esp8266 import _format_framework_arduino_version as fmt
|
||||
|
||||
assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0"
|
||||
assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0"
|
||||
assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0"
|
||||
assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0"
|
||||
# Anchored to the framework version line, not a bare EsphomeError
|
||||
with pytest.raises(cv.Invalid, match="not supported yet") as excinfo:
|
||||
fmt(cv.Version(4, 0, 0))
|
||||
assert excinfo.value.path == ["version"]
|
||||
|
||||
|
||||
def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}):
|
||||
assert framework.get_arduino8266_tools_path() == tmp_path.resolve()
|
||||
# A blank prefix must be treated as unset, not as the CWD
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}):
|
||||
path = framework.get_arduino8266_tools_path()
|
||||
assert path.name == "arduino8266"
|
||||
assert path != Path.cwd()
|
||||
|
||||
|
||||
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
|
||||
patch.object(framework, "install_package") as mock_install,
|
||||
patch.object(framework, "prefetch_packages") as mock_prefetch,
|
||||
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
|
||||
):
|
||||
paths = framework.check_and_install(cv.Version(3, 1, 2))
|
||||
assert paths.framework == tmp_path / "frameworks" / "3.30102.0"
|
||||
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
|
||||
assert paths.ninja == tmp_path / "ninja"
|
||||
assert mock_install.call_count == 2
|
||||
# Full argument pinning: a copy-paste swap between the two near-identical
|
||||
# calls (mirrors, destination) must not stay green
|
||||
fw_call, tc_call = mock_install.call_args_list
|
||||
assert fw_call.args == (
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries")
|
||||
assert tc_call.args == (
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf")
|
||||
# The prefetch sees the same package specs as the installs
|
||||
assert mock_prefetch.call_args.args == (
|
||||
[
|
||||
(
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
),
|
||||
(
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
),
|
||||
],
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
|
||||
|
||||
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
|
||||
with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep)
|
||||
assert env["CCACHE_DIR"] == "x"
|
||||
|
||||
|
||||
def test_ccache_env(tmp_path: Path) -> None:
|
||||
assert framework.ccache_env(None) == {}
|
||||
with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
# User-set values are respected; the rest get defaults
|
||||
assert "CCACHE_NOHASHDIR" not in env
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
|
||||
"""Calling the installer below the floor fails before any download."""
|
||||
with pytest.raises(EsphomeError, match=">= 3.1.1"):
|
||||
framework.check_and_install(cv.Version(3, 0, 2))
|
||||
|
||||
|
||||
def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None:
|
||||
"""An absent PATH must not leave a trailing separator (an empty entry
|
||||
means the current directory to the shell)."""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"] == str(tmp_path / "bin")
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ, {"PATH": f"/usr/bin{os.pathsep}{os.pathsep}/bin"}, clear=True
|
||||
),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"]
|
||||
|
||||
|
||||
def test_ccache_env_accepts_a_preresolved_path() -> None:
|
||||
"""The caller resolves ccache once and threads it through; None means
|
||||
resolved-and-disabled."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert framework.ccache_env(None) == {}
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_toolchain_tool_layout(tmp_path: Path) -> None:
|
||||
"""One owner for the bin/xtensa-lx106-elf-<name> layout."""
|
||||
tool = framework.toolchain_tool(tmp_path, "addr2line")
|
||||
assert tool.parent == tmp_path / "bin"
|
||||
assert tool.name.startswith("xtensa-lx106-elf-addr2line")
|
||||
assert (tool.suffix == ".exe") is (os.name == "nt")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
"""Tests for the async thread helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.async_thread import AsyncDispatchTimeout, AsyncThreadRunner, run_async
|
||||
|
||||
|
||||
def _cleanup_threads() -> set[threading.Thread]:
|
||||
"""Return the currently live orphan-cleanup threads."""
|
||||
return {t for t in threading.enumerate() if t.name == "async-orphan-cleanup"}
|
||||
|
||||
|
||||
def _join_new_cleanup_threads(before: set[threading.Thread]) -> None:
|
||||
"""Wait for cleanup threads spawned since ``before`` to finish."""
|
||||
for thread in _cleanup_threads() - before:
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
|
||||
|
||||
def test_run_async_returns_result() -> None:
|
||||
"""The coroutine's result is returned to the sync caller."""
|
||||
|
||||
async def coro() -> int:
|
||||
await asyncio.sleep(0)
|
||||
return 42
|
||||
|
||||
assert run_async(coro) == 42
|
||||
|
||||
|
||||
def test_run_async_propagates_exception() -> None:
|
||||
"""Exceptions raised by the coroutine surface in the caller."""
|
||||
|
||||
async def coro() -> None:
|
||||
raise ValueError("boom")
|
||||
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
run_async(coro)
|
||||
|
||||
|
||||
def test_run_async_propagates_base_exception() -> None:
|
||||
"""A BaseException from the coroutine surfaces instead of a None result."""
|
||||
|
||||
class Boom(BaseException):
|
||||
pass
|
||||
|
||||
async def coro() -> None:
|
||||
raise Boom
|
||||
|
||||
with pytest.raises(Boom):
|
||||
run_async(coro)
|
||||
|
||||
|
||||
def test_run_async_timeout() -> None:
|
||||
"""A coroutine that does not finish in time raises TimeoutError."""
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.05)
|
||||
|
||||
# Unblock the abandoned runner so its cleanup thread exits promptly.
|
||||
release.set()
|
||||
_join_new_cleanup_threads(before)
|
||||
|
||||
|
||||
def test_run_async_surfaces_loop_startup_failure() -> None:
|
||||
"""A failure before the coroutine runs raises instead of hanging."""
|
||||
|
||||
def failing_run(main: Any) -> None:
|
||||
# Close the never-awaited coroutine so the test does not leave a
|
||||
# RuntimeWarning attributed to whatever module GC runs in later.
|
||||
main.close()
|
||||
raise OSError("no fds for the event loop")
|
||||
|
||||
with (
|
||||
patch("esphome.async_thread.asyncio.run", side_effect=failing_run),
|
||||
pytest.raises(OSError, match="no fds"),
|
||||
):
|
||||
run_async(lambda: asyncio.sleep(0), timeout=5)
|
||||
|
||||
|
||||
def test_run_preserves_result_when_cleanup_fails(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A loop-cleanup failure after success is logged, not raised."""
|
||||
|
||||
async def coro() -> str:
|
||||
return "ok"
|
||||
|
||||
runner: AsyncThreadRunner[str] = AsyncThreadRunner(coro)
|
||||
|
||||
def fake_run(main: Any) -> None:
|
||||
main.close()
|
||||
# Emulate _runner delivering the result before cleanup raised. A
|
||||
# None result must count as delivered too, hence the completed flag.
|
||||
runner.result = "ok"
|
||||
runner.completed = True
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with (
|
||||
caplog.at_level("DEBUG", logger="esphome.async_thread"),
|
||||
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
|
||||
):
|
||||
runner.run()
|
||||
|
||||
assert runner.event.is_set()
|
||||
assert runner.exception is None
|
||||
assert runner.result == "ok"
|
||||
assert "teardown failed after outcome recorded" in caplog.text
|
||||
|
||||
|
||||
def test_run_async_none_result_is_success() -> None:
|
||||
"""A coroutine legitimately returning None is not treated as a failure."""
|
||||
|
||||
async def coro() -> None:
|
||||
return None
|
||||
|
||||
assert run_async(coro) is None
|
||||
|
||||
|
||||
def test_run_async_on_orphan_skips_none_result() -> None:
|
||||
"""A late None result completes cleanly without invoking on_orphan."""
|
||||
orphaned: list[Any] = []
|
||||
finished = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
finished.set()
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
|
||||
|
||||
release.set()
|
||||
assert finished.wait(5)
|
||||
_join_new_cleanup_threads(before)
|
||||
assert not orphaned
|
||||
|
||||
|
||||
def test_late_failure_without_on_orphan_is_logged(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An abandoned thread's real error leaves a visible trace."""
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> str:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
raise ValueError("the real cause")
|
||||
|
||||
before = _cleanup_threads()
|
||||
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01)
|
||||
|
||||
release.set()
|
||||
_join_new_cleanup_threads(before)
|
||||
assert "Abandoned async operation failed" in caplog.text
|
||||
assert "the real cause" in caplog.text
|
||||
|
||||
|
||||
def test_run_async_on_orphan_failure_is_contained(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An on_orphan callback that raises is logged, not propagated."""
|
||||
released = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def on_orphan(result: str) -> None:
|
||||
released.set()
|
||||
raise OSError("close failed")
|
||||
|
||||
async def coro() -> str:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
return "late result"
|
||||
|
||||
before = _cleanup_threads()
|
||||
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01, on_orphan=on_orphan)
|
||||
|
||||
release.set()
|
||||
assert released.wait(5)
|
||||
_join_new_cleanup_threads(before)
|
||||
assert "Error releasing orphaned result" in caplog.text
|
||||
|
||||
|
||||
def test_run_async_on_orphan_releases_late_result() -> None:
|
||||
"""A result produced after the timeout is handed to on_orphan."""
|
||||
orphaned: list[Any] = []
|
||||
delivered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def on_orphan(result: str) -> None:
|
||||
orphaned.append(result)
|
||||
delivered.set()
|
||||
|
||||
async def coro() -> str:
|
||||
# Block until the test has observed the timeout, so the result is
|
||||
# guaranteed to arrive late no matter how slowly the runner is
|
||||
# scheduled.
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
return "late result"
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01, on_orphan=on_orphan)
|
||||
|
||||
release.set()
|
||||
assert delivered.wait(5)
|
||||
_join_new_cleanup_threads(before)
|
||||
assert orphaned == ["late result"]
|
||||
|
||||
|
||||
def test_run_async_on_orphan_skips_late_failure() -> None:
|
||||
"""A late failure after the timeout is not handed to on_orphan."""
|
||||
orphaned: list[Any] = []
|
||||
failed = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> str:
|
||||
# Block until the test has observed the timeout, so the failure is
|
||||
# guaranteed to arrive late.
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
failed.set()
|
||||
raise ValueError("late failure")
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
|
||||
|
||||
release.set()
|
||||
assert failed.wait(5)
|
||||
_join_new_cleanup_threads(before)
|
||||
assert not orphaned
|
||||
|
||||
|
||||
def test_run_async_detects_missing_outcome() -> None:
|
||||
"""A run that records neither result nor exception raises loudly."""
|
||||
|
||||
def fake_run(main: Any) -> None:
|
||||
# Simulate a loop that silently dropped the coroutine.
|
||||
main.close()
|
||||
|
||||
with (
|
||||
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
|
||||
pytest.raises(RuntimeError, match="without a result"),
|
||||
):
|
||||
run_async(lambda: asyncio.sleep(0), timeout=5)
|
||||
|
||||
|
||||
def test_run_async_raises_distinguishable_timeout() -> None:
|
||||
"""The dispatcher's own expiry is a distinct TimeoutError subclass."""
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(AsyncDispatchTimeout):
|
||||
run_async(coro, timeout=0.01)
|
||||
release.set()
|
||||
_join_new_cleanup_threads(before)
|
||||
|
||||
|
||||
def test_orphan_watcher_gives_up_on_a_hung_coroutine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The watcher exits after its bound instead of parking forever."""
|
||||
from esphome import async_thread
|
||||
|
||||
monkeypatch.setattr(async_thread, "ORPHAN_WAIT_TIMEOUT", 0.01)
|
||||
release = threading.Event()
|
||||
orphaned: list[Any] = []
|
||||
|
||||
async def coro() -> str:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
return "too late"
|
||||
|
||||
before = _cleanup_threads()
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
|
||||
|
||||
_join_new_cleanup_threads(before)
|
||||
assert not orphaned
|
||||
release.set()
|
||||
|
||||
|
||||
def test_late_real_result_without_handler_is_logged(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A genuinely dropped late result leaves the discard trace."""
|
||||
release = threading.Event()
|
||||
|
||||
async def coro() -> str:
|
||||
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
||||
return "dropped"
|
||||
|
||||
before = _cleanup_threads()
|
||||
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
|
||||
with pytest.raises(TimeoutError):
|
||||
run_async(coro, timeout=0.01)
|
||||
|
||||
release.set()
|
||||
_join_new_cleanup_threads(before)
|
||||
assert "Discarding late result" in caplog.text
|
||||
+485
-25
@@ -22,13 +22,16 @@ from esphome.bundle import (
|
||||
_add_bytes_to_tar,
|
||||
_default_target_dir,
|
||||
_find_used_secret_keys,
|
||||
add_bundle_file,
|
||||
add_secret_scan_dir,
|
||||
extract_bundle,
|
||||
is_bundle_path,
|
||||
prepare_bundle_for_compile,
|
||||
read_bundle_manifest,
|
||||
remap_bundle_path,
|
||||
)
|
||||
from esphome.components.substitutions import do_substitution_pass
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.yaml_util import force_load_include_files
|
||||
from esphome.yaml_util import force_load_include_files, load_yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -96,26 +99,6 @@ def _setup_config_dir(
|
||||
return config_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_bundle_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
(f"my_device{BUNDLE_EXTENSION}", True),
|
||||
(f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True),
|
||||
("my_device.yaml", False),
|
||||
("my_device.tar.gz", False),
|
||||
("my_device.zip", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_is_bundle_path(filename: str, expected: bool) -> None:
|
||||
assert is_bundle_path(Path(filename)) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _default_target_dir
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -477,7 +460,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 +474,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 +494,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 +858,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(
|
||||
@@ -918,7 +1229,8 @@ def test_discover_files_deeply_nested_include(tmp_path: Path) -> None:
|
||||
def test_discover_files_nested_include_unresolved_substitution(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""!include with substitution vars in path cannot be resolved; skipped gracefully."""
|
||||
"""!include with substitution vars in path but no candidate files on disk
|
||||
(the glob's only match is the config itself) is skipped gracefully."""
|
||||
config_dir = _setup_config_dir(tmp_path)
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\nwifi: !include ${platform}.yaml\n"
|
||||
@@ -932,6 +1244,115 @@ def test_discover_files_nested_include_unresolved_substitution(
|
||||
assert "test.yaml" in paths
|
||||
|
||||
|
||||
def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None:
|
||||
"""The issue-17650 layout: templated package includes chain through a glob
|
||||
candidate into a Jinja conditional whose ``../`` branch is bundled."""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={
|
||||
"includes/esp-basics.yaml": (
|
||||
"packages:\n"
|
||||
" - !include boards/${board}.yaml\n"
|
||||
" - !include keys/${system_name}.yaml\n"
|
||||
),
|
||||
"includes/boards/wemos-d1-mini.yaml": (
|
||||
'packages:\n - !include ${ "NO BT.yaml" if bt else "../empty.yaml" }\n'
|
||||
),
|
||||
"includes/keys/device-a.yaml": "api:\n",
|
||||
"includes/keys/device-b.yaml": "api:\n",
|
||||
"includes/empty.yaml": "{}\n",
|
||||
},
|
||||
)
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\npackages:\n - !include includes/esp-basics.yaml\n"
|
||||
)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "includes/esp-basics.yaml" in paths
|
||||
assert "includes/boards/wemos-d1-mini.yaml" in paths
|
||||
assert "includes/keys/device-a.yaml" in paths
|
||||
assert "includes/keys/device-b.yaml" in paths
|
||||
assert "includes/empty.yaml" in paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_proxy", [True, False])
|
||||
def test_bundle_roundtrip_templated_include_with_path_separator(
|
||||
tmp_path: Path, enable_proxy: bool
|
||||
) -> None:
|
||||
r"""The issue-18545 flow: a Jinja !include whose branches contain "/" still
|
||||
resolves after the bundle is extracted on the build server.
|
||||
|
||||
Windows is the leg that regresses: the raw expression text must survive
|
||||
verbatim, or its separators get rewritten to "\" and Jinja decodes
|
||||
sequences like "\b" as string escapes.
|
||||
"""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={
|
||||
"includes/boards/board.yaml": (
|
||||
"packages:\n"
|
||||
' - !include ${ "bluetooth/bluetooth_proxy_single_core.yaml"'
|
||||
' if enable_bluetooth_proxy else "../empty.yaml" }\n'
|
||||
),
|
||||
"includes/boards/bluetooth/bluetooth_proxy_single_core.yaml": (
|
||||
"bluetooth_proxy:\n active: true\n"
|
||||
),
|
||||
"includes/empty.yaml": "{}\n",
|
||||
},
|
||||
)
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"substitutions:\n"
|
||||
f" enable_bluetooth_proxy: {str(enable_proxy).lower()}\n"
|
||||
"esphome:\n name: test\n"
|
||||
"packages:\n - !include includes/boards/board.yaml\n"
|
||||
)
|
||||
|
||||
result = ConfigBundleCreator({}).create_bundle()
|
||||
bundle_path = tmp_path / "device.esphomebundle.tar.gz"
|
||||
bundle_path.write_bytes(result.data)
|
||||
|
||||
# Both conditional branches must ship in the bundle.
|
||||
paths = [f.path for f in result.files]
|
||||
assert "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml" in paths
|
||||
assert "includes/empty.yaml" in paths
|
||||
|
||||
# Extract to a fresh directory and resolve the config from there, as a
|
||||
# remote build server would.
|
||||
extracted_config = extract_bundle(bundle_path, tmp_path / "remote")
|
||||
config = do_substitution_pass(load_yaml(extracted_config))
|
||||
|
||||
board_pkg = config["packages"][0]["packages"][0]
|
||||
if enable_proxy:
|
||||
assert board_pkg == {"bluetooth_proxy": {"active": True}}
|
||||
else:
|
||||
assert board_pkg == {}
|
||||
|
||||
|
||||
def test_discover_files_candidate_outside_config_dir_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A candidate branch resolving above the config dir is not bundled."""
|
||||
config_dir = _setup_config_dir(tmp_path)
|
||||
(tmp_path / "outside.yaml").write_text("api:\n")
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\n"
|
||||
'wifi: !include ${ "a.yaml" if x else "../outside.yaml" }\n'
|
||||
)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert not any("outside" in p for p in paths)
|
||||
assert any(
|
||||
"outside config directory" in r.message and "outside.yaml" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_discover_files_nested_include_load_failure(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -1196,7 +1617,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 +1625,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]
|
||||
|
||||
|
||||
@@ -1263,6 +1685,44 @@ def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None:
|
||||
assert "unused" not in secrets_data
|
||||
|
||||
|
||||
def test_create_bundle_scans_remote_package_files_for_secrets(tmp_path: Path) -> None:
|
||||
"""Secrets referenced only by git-fetched package files must be shipped
|
||||
in the filtered secrets.yaml (regression test for issue 18023)."""
|
||||
config_dir = _setup_config_dir(tmp_path)
|
||||
|
||||
secrets = config_dir / "secrets.yaml"
|
||||
secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n")
|
||||
|
||||
# Simulate a git-fetched package checkout referencing a secret
|
||||
repo_dir = config_dir / ".esphome" / "packages" / "6bcd6aa8"
|
||||
package_dir = repo_dir / "packages"
|
||||
package_dir.mkdir(parents=True)
|
||||
(package_dir / "base.yml").write_text(
|
||||
"ota:\n - platform: esphome\n password: !secret ota_password\n"
|
||||
)
|
||||
# References inside hidden directories such as .git must not be scanned
|
||||
hidden_dir = repo_dir / ".git"
|
||||
hidden_dir.mkdir()
|
||||
(hidden_dir / "leak.yaml").write_text("password: !secret unused\n")
|
||||
add_secret_scan_dir(repo_dir)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
result = creator.create_bundle()
|
||||
|
||||
assert result.manifest[ManifestKey.HAS_SECRETS] is True
|
||||
|
||||
buf = io.BytesIO(result.data)
|
||||
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
|
||||
secrets_data = tar.extractfile("secrets.yaml").read().decode()
|
||||
names = tar.getnames()
|
||||
|
||||
assert "ota_password" in secrets_data
|
||||
assert "hunter2" in secrets_data
|
||||
assert "unused" not in secrets_data
|
||||
# The package checkout itself must not be bundled
|
||||
assert not any("base.yml" in name for name in names)
|
||||
|
||||
|
||||
def test_create_bundle_no_secrets(tmp_path: Path) -> None:
|
||||
_setup_config_dir(tmp_path)
|
||||
|
||||
|
||||
@@ -2,57 +2,73 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.__main__ import run_esphome
|
||||
from esphome.compiled_config import (
|
||||
_LAMBDA_KEY,
|
||||
compiled_config_path,
|
||||
load_compiled_config,
|
||||
save_compiled_config,
|
||||
save_compiled_config_and_sidecar,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_ESPHOME,
|
||||
CONF_NAME,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
ID,
|
||||
EsphomeError,
|
||||
HexInt,
|
||||
Lambda,
|
||||
MACAddress,
|
||||
TimePeriodMilliseconds,
|
||||
)
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.util import OrderedDict
|
||||
|
||||
_VALIDATED_CONFIG_YAML = """\
|
||||
esphome:
|
||||
name: lite_test
|
||||
friendly_name: Lite Test Device
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
logger:
|
||||
baud_rate: 115200
|
||||
api:
|
||||
port: 6053
|
||||
encryption:
|
||||
key: 6dGhpcyBpcyBhIHRlc3Q=
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3232
|
||||
password: secret
|
||||
wifi:
|
||||
ssid: ssid
|
||||
use_address: 192.168.1.42
|
||||
"""
|
||||
_VALIDATED_CONFIG = {
|
||||
"esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"},
|
||||
"esp32": {"board": "nodemcu-32s"},
|
||||
"logger": {"baud_rate": 115200},
|
||||
"api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}},
|
||||
"ota": [{"platform": "esphome", "port": 3232, "password": "secret"}],
|
||||
"wifi": {"ssid": "ssid", "use_address": "192.168.1.42"},
|
||||
}
|
||||
|
||||
|
||||
def _cache_body(config: dict | None = None) -> str:
|
||||
"""Render the JSON envelope the production save writes."""
|
||||
return json.dumps(
|
||||
{"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG}
|
||||
)
|
||||
|
||||
|
||||
def _write_storage(
|
||||
storage_path: Path,
|
||||
*,
|
||||
esp_platform: str = "ESP32",
|
||||
esp_platform: str | None = "ESP32",
|
||||
core_platform: str | None = "esp32",
|
||||
build_path: str | None = "/build/lite_test",
|
||||
toolchain: str | None = None,
|
||||
) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -66,21 +82,22 @@ def _write_storage(
|
||||
"address": "192.168.1.42",
|
||||
"web_port": None,
|
||||
"esp_platform": esp_platform,
|
||||
"build_path": "/build/lite_test",
|
||||
"build_path": build_path,
|
||||
"firmware_bin_path": "/build/lite_test/firmware.bin",
|
||||
"loaded_integrations": ["api", "logger", "ota", "wifi"],
|
||||
"loaded_platforms": [],
|
||||
"no_mdns": False,
|
||||
"framework": "arduino",
|
||||
"core_platform": core_platform,
|
||||
"toolchain": toolchain,
|
||||
}
|
||||
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:
|
||||
def _write_cache(cache_path: Path, body: str | None = None) -> 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 if body is not None else _cache_body(), encoding="utf-8")
|
||||
return cache_path
|
||||
|
||||
|
||||
@@ -94,24 +111,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_cache_files(tmp_path: Path) -> Path:
|
||||
"""YAML + StorageJSON + cache, all consistent and fresh."""
|
||||
def primed_storage(tmp_path: Path) -> Path:
|
||||
"""YAML + StorageJSON sidecar, no cache yet."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
_write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json")
|
||||
return yaml_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_cache_files(primed_storage: Path) -> Path:
|
||||
"""YAML + StorageJSON + cache, all consistent and fresh."""
|
||||
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache, primed_storage, offset=5)
|
||||
return primed_storage
|
||||
|
||||
|
||||
def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
|
||||
"""The cache file shape is predictable from the YAML filename."""
|
||||
path = compiled_config_path("device.yaml")
|
||||
assert path.name == "device.yaml.validated.yaml"
|
||||
assert path.name == "device.yaml.validated.json"
|
||||
assert path.parent.name == "storage"
|
||||
|
||||
|
||||
@@ -124,28 +145,27 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
|
||||
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
||||
assert config["ota"][0]["password"] == "secret"
|
||||
|
||||
# The fast path loads plain scalars; no per-node source ranges exist.
|
||||
assert type(config[CONF_ESPHOME][CONF_NAME]) is str
|
||||
|
||||
# apply_to_core populated exactly what upload/logs read off CORE.
|
||||
assert CORE.name == "lite_test"
|
||||
assert CORE.build_path == Path("/build/lite_test")
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
|
||||
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
|
||||
|
||||
|
||||
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
|
||||
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
@@ -156,8 +176,6 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
@@ -168,7 +186,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms(
|
||||
esp_platform="ESP8266",
|
||||
core_platform="esp8266",
|
||||
)
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
@@ -185,7 +203,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
cache_path = storage_dir / "lite_test.yaml.validated.yaml"
|
||||
cache_path = storage_dir / "lite_test.yaml.validated.json"
|
||||
sidecar_path = storage_dir / "lite_test.yaml.json"
|
||||
|
||||
if scenario == "missing_cache":
|
||||
@@ -196,7 +214,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
|
||||
elif scenario == "corrupt_cache":
|
||||
_write_storage(sidecar_path)
|
||||
_set_cache_mtime(
|
||||
_write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5
|
||||
_write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5
|
||||
)
|
||||
elif scenario == "missing_sidecar":
|
||||
# Cache fresh + parseable, but no StorageJSON → can't populate CORE.
|
||||
@@ -205,6 +223,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
|
||||
assert load_compiled_config(yaml_path) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
json.dumps(
|
||||
{"v": 999, "esphome": const.__version__, "config": {"esphome": {}}}
|
||||
),
|
||||
id="wrong_version",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}),
|
||||
id="missing_version",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}),
|
||||
id="other_esphome_version",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"v": 1, "config": {"esphome": {}}}),
|
||||
id="missing_esphome_version",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps(
|
||||
{
|
||||
"v": 1,
|
||||
"esphome": const.__version__,
|
||||
"config": ["not", "a", "dict"],
|
||||
}
|
||||
),
|
||||
id="non_dict_config",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config"
|
||||
),
|
||||
pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"),
|
||||
],
|
||||
)
|
||||
def test_load_compiled_config_rejects_bad_envelope(
|
||||
primed_storage: Path, body: str
|
||||
) -> None:
|
||||
"""A foreign or future cache shape falls back instead of half-loading."""
|
||||
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body)
|
||||
_set_cache_mtime(cache, primed_storage, offset=5)
|
||||
|
||||
assert load_compiled_config(primed_storage) is None
|
||||
|
||||
|
||||
def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None:
|
||||
"""A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path."""
|
||||
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
||||
legacy = _write_cache(
|
||||
storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n"
|
||||
)
|
||||
_set_cache_mtime(legacy, primed_storage, offset=5)
|
||||
|
||||
assert load_compiled_config(primed_storage) is None
|
||||
|
||||
|
||||
def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None:
|
||||
"""A successful save leaves only the JSON cache behind."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
||||
legacy.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy.write_text("esphome:\n name: lite_test\n")
|
||||
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}})
|
||||
|
||||
assert compiled_config_path("lite_test.yaml").is_file()
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None:
|
||||
"""The secret-bearing legacy cache goes away regardless of write outcome."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
||||
legacy.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy.write_text("esphome:\n name: lite_test\n")
|
||||
|
||||
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}})
|
||||
|
||||
assert not legacy.exists()
|
||||
assert not compiled_config_path("lite_test.yaml").exists()
|
||||
|
||||
|
||||
def test_save_warns_when_legacy_cache_unremovable(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A secret-bearing legacy file that won't unlink warns; the write proceeds."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
||||
legacy.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy.mkdir() # unlink() on a directory raises OSError
|
||||
|
||||
with caplog.at_level("WARNING", logger="esphome.compiled_config"):
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}})
|
||||
|
||||
assert "legacy validated-config cache" in caplog.text
|
||||
assert compiled_config_path("lite_test.yaml").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
|
||||
command: str,
|
||||
@@ -220,7 +340,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 +362,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},
|
||||
@@ -253,31 +373,291 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without a StorageJSON sidecar (no compile has run), the fallback
|
||||
skips the cache write -- load_compiled_config requires the sidecar,
|
||||
so writing the rendered (secret-resolved) YAML would be inert and
|
||||
leak secrets to disk for nothing."""
|
||||
def _storage_fixture(tmp_path: Path) -> StorageJSON:
|
||||
"""A loaded StorageJSON instance matching _write_storage's contents."""
|
||||
fixture = tmp_path / "fixture_storage.json"
|
||||
_write_storage(fixture)
|
||||
return StorageJSON.load(fixture)
|
||||
|
||||
|
||||
def _bare_yaml(tmp_path: Path) -> Path:
|
||||
"""A minimal YAML with CORE.config_path pointed at it."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
return yaml_path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
|
||||
"""Patch the fallback path's collaborators for a run_esphome call.
|
||||
|
||||
Without kwargs, from_esphome_core stays real (yielded mock is None).
|
||||
"""
|
||||
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,
|
||||
) as mock_read,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"upload": lambda args, config: 0},
|
||||
{command: lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "upload", str(yaml_path)])
|
||||
if not from_core_kwargs:
|
||||
yield mock_read, None
|
||||
return
|
||||
with patch.object(
|
||||
StorageJSON, "from_esphome_core", **from_core_kwargs
|
||||
) as mock_from_core:
|
||||
yield mock_read, mock_from_core
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
|
||||
tmp_path: Path, command: str
|
||||
) -> None:
|
||||
"""A never-compiled config caches on its first upload/logs run: the
|
||||
fallback writes the StorageJSON sidecar itself (load_compiled_config
|
||||
needs it), so the second run hits the fast path."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
|
||||
with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
|
||||
mock_read,
|
||||
mock_from_core,
|
||||
):
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
mock_from_core.assert_called_once()
|
||||
assert (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
||||
assert storage is not None
|
||||
# No compile happened, so the sidecar must not claim one.
|
||||
assert mock_from_core.call_args.kwargs == {"claim_build": False}
|
||||
|
||||
# The second run loads the cache instead of re-validating.
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
# as_dict serialized unset paths as str(None) until 2026.9; files
|
||||
# written by those wizards are still on disk.
|
||||
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
|
||||
"wizard_kwargs",
|
||||
[
|
||||
{"esp_platform": None, "core_platform": None, "build_path": None},
|
||||
{"build_path": None},
|
||||
{"build_path": "None"},
|
||||
],
|
||||
ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
|
||||
)
|
||||
|
||||
|
||||
def _prime_core(tmp_path: Path) -> None:
|
||||
"""Set the post-validation CORE state from_esphome_core reads."""
|
||||
CORE.name = "lite_test"
|
||||
CORE.build_path = tmp_path / "build" / "lite_test"
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp8266",
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
|
||||
@_WIZARD_SIDECAR_CASES
|
||||
def test_run_esphome_fallback_completes_wizard_sidecar(
|
||||
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""A wizard-written sidecar can't drive the fast path (no build_path;
|
||||
older wizards also no platform fields); the fallback rewrites it from
|
||||
CORE so the cache loads on the next run."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
||||
|
||||
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_called_once()
|
||||
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
||||
assert storage is not None and storage.core_platform == "esp32"
|
||||
# What the wizard recorded about a build (nothing, or a real one)
|
||||
# carries through instead of being stamped with this run's values.
|
||||
assert storage.esphome_version == "2026.1.0"
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A failed sidecar write is non-fatal and skips the cache save too:
|
||||
without the sidecar the cache could never be loaded back, so writing
|
||||
it would only leave resolved secrets on disk."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
|
||||
with (
|
||||
_fallback_run(side_effect=RuntimeError("boom")),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_not_called()
|
||||
assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_write_failure_takes_io_branch(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""StorageJSON.save raises EsphomeError (write_file wraps OSError into
|
||||
it), which must land in the plain I/O warning, not the traceback
|
||||
branch for structural bugs."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
|
||||
with (
|
||||
_fallback_run(return_value=_storage_fixture(tmp_path)),
|
||||
patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
|
||||
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
||||
caplog.at_level("WARNING", logger="esphome.compiled_config"),
|
||||
):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_not_called()
|
||||
assert "Could not refresh the storage sidecar" in caplog.text
|
||||
assert "Unexpected error" not in caplog.text
|
||||
|
||||
|
||||
def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
|
||||
"""A present-but-corrupt sidecar is not overwritten: it may hold a real
|
||||
build's metadata, and replacing it would suppress the next compile's
|
||||
clean of a possibly incoherent build tree. The cache save is skipped."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
sidecar = storage_dir / "lite_test.yaml.json"
|
||||
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
||||
sidecar.write_text("{truncated", encoding="utf-8")
|
||||
|
||||
with _fallback_run(return_value=None) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_not_called()
|
||||
assert sidecar.read_text(encoding="utf-8") == "{truncated"
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""If the rebuilt sidecar would still be incomplete, nothing is written:
|
||||
the cache could never be loaded back, so saving it would only rewrite
|
||||
resolved secrets on every run."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
|
||||
incomplete = tmp_path / "incomplete_storage.json"
|
||||
_write_storage(incomplete, build_path=None)
|
||||
|
||||
with _fallback_run(return_value=StorageJSON.load(incomplete)):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
assert not (storage_dir / "lite_test.yaml.json").exists()
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
|
||||
|
||||
def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The toolchain fallback runs before the sidecar write, so platforms
|
||||
whose validators leave CORE.toolchain unset record the same
|
||||
"platformio" a compile writes, not null."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
assert CORE.toolchain is None
|
||||
|
||||
with _fallback_run():
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
storage = StorageJSON.load(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
||||
)
|
||||
assert storage is not None
|
||||
assert storage.toolchain == "platformio"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
|
||||
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
|
||||
tmp_path: Path, existing_sidecar: str | None
|
||||
) -> None:
|
||||
"""An existing build tree with a missing or wizard-only sidecar keeps
|
||||
it that way: the mismatch is what makes the next compile wipe the
|
||||
unknown tree, so the fallback writes nothing and skips the cache."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.build_path.mkdir(parents=True)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
if existing_sidecar == "wizard":
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
|
||||
wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
||||
|
||||
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
||||
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
||||
|
||||
mock_from_core.assert_not_called()
|
||||
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
||||
if existing_sidecar == "wizard":
|
||||
sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
||||
assert sidecar_body == wizard_body
|
||||
else:
|
||||
assert not (storage_dir / "lite_test.yaml.json").exists()
|
||||
|
||||
|
||||
def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
|
||||
"""Drive the real from_esphome_core on the fallback path: the
|
||||
post-validation CORE state yields a complete, loadable sidecar."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
save_compiled_config_and_sidecar(CORE.config)
|
||||
|
||||
storage = StorageJSON.load(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
||||
)
|
||||
assert storage is not None
|
||||
assert storage.core_platform == "esp8266"
|
||||
assert storage.build_path is not None
|
||||
# No compile happened, so the sidecar must not claim one.
|
||||
assert storage.esphome_version is None
|
||||
assert storage.firmware_bin_path is None
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sidecar_toolchain", "saved"),
|
||||
[
|
||||
("esp-idf", False),
|
||||
("platformio", True),
|
||||
(None, True), # legacy sidecar without the field: guard is inert
|
||||
],
|
||||
)
|
||||
def test_save_compiled_config_and_sidecar_toolchain_mismatch(
|
||||
tmp_path: Path, sidecar_toolchain: str | None, saved: bool
|
||||
) -> None:
|
||||
"""A config validated under a different toolchain than the compile's
|
||||
must not overwrite the cache."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
_write_storage(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json",
|
||||
toolchain=sidecar_toolchain,
|
||||
)
|
||||
|
||||
save_compiled_config_and_sidecar(CORE.config)
|
||||
|
||||
cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json"
|
||||
assert cache.exists() is saved
|
||||
assert (load_compiled_config(yaml_path) is not None) is saved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
@@ -293,16 +673,17 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
|
||||
|
||||
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,
|
||||
patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{command: lambda args, config: 0},
|
||||
@@ -311,6 +692,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
||||
|
||||
mock_save.assert_called_once_with(fresh_config)
|
||||
# The compile-written sidecar is complete; the fallback leaves it alone.
|
||||
mock_from_core.assert_not_called()
|
||||
# mtime is now newer than the source YAML, so a follow-up call hits
|
||||
# the fast path instead of repeating read_config.
|
||||
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
|
||||
@@ -322,7 +705,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 +724,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 +743,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 +757,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},
|
||||
@@ -386,47 +769,171 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None
|
||||
|
||||
|
||||
def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
|
||||
"""`save_compiled_config` writes the dumped YAML next to the sidecar."""
|
||||
"""`save_compiled_config` writes the JSON envelope next to the sidecar."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})
|
||||
|
||||
cache_path = compiled_config_path("lite_test.yaml")
|
||||
assert cache_path.is_file()
|
||||
body = cache_path.read_text()
|
||||
assert "name: lite_test" in body
|
||||
assert "logger:" in body
|
||||
envelope = json.loads(cache_path.read_text())
|
||||
assert envelope["v"] == 1
|
||||
assert envelope["esphome"] == const.__version__
|
||||
assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}}
|
||||
|
||||
|
||||
def test_save_compiled_config_swallows_dump_errors(
|
||||
def test_save_compiled_config_swallows_write_errors(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Failures during the dump are non-fatal -- a bad cache just means
|
||||
"""Failures during the write are non-fatal -- a bad cache just means
|
||||
the next fast path falls back to read_config()."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")):
|
||||
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}})
|
||||
assert not compiled_config_path("lite_test.yaml").exists()
|
||||
|
||||
|
||||
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
|
||||
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
|
||||
can't drive upload/logs, so the fast path falls back."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
def test_save_stringifies_unknown_values(tmp_path: Path) -> None:
|
||||
"""A type with no dedicated encoding stores its string form."""
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
# StorageJSON with both core_platform and target_platform unset.
|
||||
(storage_dir / "lite_test.yaml.json").write_text(
|
||||
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
|
||||
'"comment": null, "esphome_version": null, "src_version": 1, '
|
||||
'"address": null, "web_port": null, "esp_platform": null, '
|
||||
'"build_path": null, "firmware_bin_path": null, '
|
||||
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
|
||||
'"framework": null, "core_platform": null}'
|
||||
class Weird:
|
||||
def __str__(self) -> str:
|
||||
return "weird-str"
|
||||
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}})
|
||||
envelope = json.loads(compiled_config_path("lite_test.yaml").read_text())
|
||||
assert envelope["config"]["esphome"]["weird"] == "weird-str"
|
||||
|
||||
|
||||
def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None:
|
||||
"""A non-basic dict key aborts the write; the fast path falls back."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
save_compiled_config({"esphome": {("a", "b"): "lite_test"}})
|
||||
assert not compiled_config_path("lite_test.yaml").exists()
|
||||
|
||||
|
||||
def _normalize(value: Any) -> Any:
|
||||
"""Make Lambda comparable; everything else compares by value already."""
|
||||
if isinstance(value, Lambda):
|
||||
return ("__lambda__", value.value)
|
||||
if isinstance(value, dict):
|
||||
return {k: _normalize(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _round_trip_config() -> OrderedDict:
|
||||
"""A post-validation shaped config exercising every representer type."""
|
||||
return OrderedDict(
|
||||
{
|
||||
"esphome": OrderedDict(
|
||||
{
|
||||
"name": "lite_test",
|
||||
"build_path": Path("/build/lite_test"),
|
||||
"on_boot": [
|
||||
OrderedDict(
|
||||
{
|
||||
"trigger_id": ID("trigger_1", type="Trigger"),
|
||||
"then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}],
|
||||
}
|
||||
)
|
||||
],
|
||||
}
|
||||
),
|
||||
"wifi": OrderedDict(
|
||||
{
|
||||
"id": ID("wifi_id", type="WiFiComponent"),
|
||||
"reboot_timeout": TimePeriodMilliseconds(milliseconds=900000),
|
||||
"use_address": IPv4Address("192.168.1.42"),
|
||||
"subnet": IPv4Network("192.168.1.0/24"),
|
||||
"mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01),
|
||||
}
|
||||
),
|
||||
"misc": OrderedDict(
|
||||
{
|
||||
"uuid": UUID("12345678-1234-5678-1234-567812345678"),
|
||||
"toolchain": Toolchain.PLATFORMIO,
|
||||
"hex": HexInt(0x1234),
|
||||
"levels": (1, 2.5, True, None),
|
||||
"empty": {},
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
|
||||
|
||||
def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None:
|
||||
"""The JSON cache loads the same tree the YAML cache used to."""
|
||||
config = _round_trip_config()
|
||||
save_compiled_config(config)
|
||||
from_json = load_compiled_config(primed_storage)
|
||||
assert from_json is not None
|
||||
|
||||
yaml_cache = primed_storage.parent / "dumped.yaml"
|
||||
yaml_cache.write_text(yaml_util.dump(config, show_secrets=True))
|
||||
from_yaml = yaml_util.load_yaml(
|
||||
yaml_cache, clear_secrets=False, track_document_range=False
|
||||
)
|
||||
|
||||
assert _normalize(from_json) == _normalize(from_yaml)
|
||||
|
||||
|
||||
def test_lambda_sentinel_round_trips(primed_storage: Path) -> None:
|
||||
"""A !lambda body comes back as a Lambda with the same source."""
|
||||
body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";'
|
||||
save_compiled_config(
|
||||
{
|
||||
"esphome": {"name": "lite_test"},
|
||||
"script": [{"then": [{"lambda": Lambda(body)}]}],
|
||||
}
|
||||
)
|
||||
|
||||
config = load_compiled_config(primed_storage)
|
||||
assert config is not None
|
||||
revived = config["script"][0]["then"][0]["lambda"]
|
||||
assert isinstance(revived, Lambda)
|
||||
assert revived.value == body
|
||||
|
||||
|
||||
def test_object_hook_requires_exact_shape(primed_storage: Path) -> None:
|
||||
"""Only the exact one-key string-valued sentinel revives a Lambda."""
|
||||
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
||||
config = {
|
||||
"esphome": {"name": "lite_test"},
|
||||
"extra_key": {_LAMBDA_KEY: "x", "y": 1},
|
||||
"non_str": {_LAMBDA_KEY: 5},
|
||||
}
|
||||
cache = _write_cache(
|
||||
storage_dir / "lite_test.yaml.validated.json", _cache_body(config)
|
||||
)
|
||||
_set_cache_mtime(cache, primed_storage, offset=5)
|
||||
|
||||
loaded = load_compiled_config(primed_storage)
|
||||
assert loaded is not None
|
||||
assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1}
|
||||
assert loaded["non_str"] == {_LAMBDA_KEY: 5}
|
||||
|
||||
|
||||
def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
|
||||
"""Non-str basic keys stringify; validated configs only use string keys."""
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}})
|
||||
|
||||
config = load_compiled_config(primed_storage)
|
||||
assert config is not None
|
||||
assert config["table"] == {"1": "a", "2": "b"}
|
||||
|
||||
|
||||
@_WIZARD_SIDECAR_CASES
|
||||
def test_load_compiled_config_rejects_wizard_only_sidecar(
|
||||
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
||||
) -> None:
|
||||
"""A wizard-written sidecar (no build_path; older wizards also no
|
||||
platform fields) can't drive upload/logs, so the fast path falls back."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
||||
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
||||
_set_cache_mtime(cache_path, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is None
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
|
||||
import pytest
|
||||
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
get_logger_level,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_LEVEL,
|
||||
CONF_LOGGER,
|
||||
@@ -12,6 +19,7 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import Define
|
||||
|
||||
|
||||
def test_filter_source_files_from_platform_esp32() -> None:
|
||||
@@ -133,3 +141,34 @@ def test_get_logger_level() -> None:
|
||||
mock_config = {CONF_LOGGER: {}}
|
||||
with patch("esphome.config_helpers.CORE.config", mock_config):
|
||||
assert get_logger_level() == "DEBUG"
|
||||
|
||||
|
||||
def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
|
||||
assert frameworks_for_platforms(["esp32"]) == {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
}
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
frameworks_for_platforms(["esp32", "not_a_platform"])
|
||||
|
||||
|
||||
def test_filter_source_files_from_defines() -> None:
|
||||
"""Files are excluded unless one of their defines is set."""
|
||||
files_map: dict[str, str | tuple[str, ...]] = {
|
||||
"filter.cpp": "USE_SENSOR_FILTER",
|
||||
"automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"),
|
||||
}
|
||||
filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map)
|
||||
|
||||
with patch("esphome.config_helpers.CORE") as mock_core:
|
||||
mock_core.defines = {Define("USE_SENSOR_FILTER")}
|
||||
assert filter_func() == ["automation.cpp"]
|
||||
|
||||
mock_core.defines = {Define("USE_MULTI_CLICK")}
|
||||
assert filter_func() == ["filter.cpp"]
|
||||
|
||||
mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")}
|
||||
assert filter_func() == []
|
||||
|
||||
mock_core.defines = set()
|
||||
assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"]
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config, yaml_util
|
||||
from esphome import config, config_validation as cv, yaml_util
|
||||
from esphome.core import CORE, AutoLoad
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -127,12 +127,14 @@ def _run_load_step(
|
||||
domain: str,
|
||||
conf: object,
|
||||
migrate: Callable[[ConfigType], list | None] | None,
|
||||
expand: Callable[[list], list] | None = None,
|
||||
) -> config.Config:
|
||||
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
|
||||
"""Run a LoadValidationStep for a platform component with given hooks."""
|
||||
component = Mock()
|
||||
component.is_platform_component = True
|
||||
component.multi_conf_no_default = False
|
||||
component.legacy_config_migrate = migrate
|
||||
component.expand_platform_config = expand
|
||||
|
||||
result = config.Config()
|
||||
with (
|
||||
@@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None:
|
||||
assert result["image"] == [auto]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart
|
||||
# to legacy_config_migrate; runs after legacy migration/list normalization.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_expand_hook_rewrites_conf() -> None:
|
||||
"""A config the expand hook rewrites is replaced with the expanded list."""
|
||||
expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}]
|
||||
expand = Mock(return_value=expanded)
|
||||
|
||||
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
|
||||
|
||||
expand.assert_called_once_with([{"platform": "file", "id": "a"}])
|
||||
assert result["image"] == expanded
|
||||
|
||||
|
||||
def test_expand_hook_absent_is_noop() -> None:
|
||||
"""A platform component without the hook is left as normalized by the
|
||||
existing list-wrapping logic."""
|
||||
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None)
|
||||
|
||||
assert result["image"] == [{"platform": "file", "id": "a"}]
|
||||
|
||||
|
||||
def test_expand_hook_runs_after_legacy_migrate() -> None:
|
||||
"""The expand hook sees the already-migrated list, not the raw legacy conf."""
|
||||
migrated = [{"platform": "file", "id": "a"}]
|
||||
migrate = Mock(return_value=migrated)
|
||||
expand = Mock(side_effect=lambda conf: conf)
|
||||
|
||||
_run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand)
|
||||
|
||||
expand.assert_called_once_with(migrated)
|
||||
|
||||
|
||||
def test_expand_hook_skipped_for_non_dict_entry() -> None:
|
||||
"""Malformed entries are left alone; the hook only sees `platform:`-tagged dicts."""
|
||||
expand = Mock(side_effect=lambda conf: conf)
|
||||
|
||||
result = _run_load_step("image", ["not-a-dict"], None, expand)
|
||||
|
||||
expand.assert_not_called()
|
||||
assert result["image"] == ["not-a-dict"]
|
||||
|
||||
|
||||
def test_expand_hook_skipped_for_entry_missing_platform_key() -> None:
|
||||
"""A dict entry missing the `platform:` key is left alone -- the normal
|
||||
per-entry error reporting further down catches this case instead."""
|
||||
expand = Mock(side_effect=lambda conf: conf)
|
||||
|
||||
result = _run_load_step("image", [{"id": "a"}], None, expand)
|
||||
|
||||
expand.assert_not_called()
|
||||
assert result["image"] == [{"id": "a"}]
|
||||
|
||||
|
||||
def test_expand_hook_skipped_for_autoload() -> None:
|
||||
"""A non-empty AutoLoad reaching the hook stage is left alone."""
|
||||
expand = Mock(side_effect=lambda conf: conf)
|
||||
auto = AutoLoad()
|
||||
auto["id"] = "a"
|
||||
|
||||
result = _run_load_step("image", auto, None, expand)
|
||||
|
||||
expand.assert_not_called()
|
||||
assert result["image"] == [auto]
|
||||
|
||||
|
||||
def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None:
|
||||
"""The guard does not block the normal, well-formed case."""
|
||||
expand = Mock(side_effect=lambda conf: conf)
|
||||
conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}]
|
||||
|
||||
result = _run_load_step("image", conf, None, expand)
|
||||
|
||||
expand.assert_called_once_with(conf)
|
||||
assert result["image"] == conf
|
||||
|
||||
|
||||
def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None:
|
||||
"""A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs."""
|
||||
expand = Mock(side_effect=cv.Invalid("bad shape"))
|
||||
pre_expand_conf = [{"platform": "file", "id": "a"}]
|
||||
|
||||
result = _run_load_step("image", pre_expand_conf, None, expand)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].path == ["image"]
|
||||
assert "bad shape" in str(result.errors[0])
|
||||
assert result["image"] == pre_expand_conf
|
||||
|
||||
|
||||
def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None:
|
||||
"""`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended)."""
|
||||
already_resolved_error = cv.FinalExternalInvalid(
|
||||
"bad shape", path=["image", 3, "files"]
|
||||
)
|
||||
expand = Mock(side_effect=already_resolved_error)
|
||||
pre_expand_conf = [{"platform": "file", "id": "a"}]
|
||||
|
||||
result = _run_load_step("image", pre_expand_conf, None, expand)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0] is already_resolved_error
|
||||
assert result.errors[0].path == ["image", 3, "files"]
|
||||
assert result["image"] == pre_expand_conf
|
||||
|
||||
|
||||
def test_expand_hook_non_list_return_raises_type_error() -> None:
|
||||
"""A non-list return is a component bug: it escapes as an uncaught TypeError
|
||||
(explicit raise survives -O/-OO)."""
|
||||
expand = Mock(return_value={"not": "a list"})
|
||||
|
||||
with pytest.raises(TypeError, match="must return a list"):
|
||||
_run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
|
||||
|
||||
|
||||
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
|
||||
"""Create a config where two `<<` includes both define `logger:`.
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Tests for the remote file prefetch validation step."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import core
|
||||
from esphome.config import Config, PrefetchRemoteFilesValidationStep
|
||||
import esphome.config_validation as cv
|
||||
from esphome.external_files import RemoteFile
|
||||
|
||||
|
||||
def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
is_platform_component=is_platform,
|
||||
prefetch_files=prefetch,
|
||||
)
|
||||
|
||||
|
||||
def _run_step(
|
||||
domains: dict[str, Any],
|
||||
components: dict[str, Any],
|
||||
platforms: dict[tuple[str, str], Any] | None = None,
|
||||
download_side_effect: Any = None,
|
||||
) -> tuple[Config, MagicMock]:
|
||||
result = Config()
|
||||
for domain, conf in domains.items():
|
||||
result[domain] = conf
|
||||
with (
|
||||
patch("esphome.config.get_component", side_effect=components.get),
|
||||
patch(
|
||||
"esphome.config.get_platform",
|
||||
side_effect=lambda d, p: (platforms or {}).get((d, p)),
|
||||
),
|
||||
patch(
|
||||
"esphome.external_files.download_content_many",
|
||||
side_effect=download_side_effect,
|
||||
) as mock_download,
|
||||
):
|
||||
PrefetchRemoteFilesValidationStep().run(result)
|
||||
return result, mock_download
|
||||
|
||||
|
||||
def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]:
|
||||
return list(mock_download.call_args_list[call][0][0])
|
||||
|
||||
|
||||
def test_component_hook_receives_normalized_entries() -> None:
|
||||
"""A bare dict conf is passed to the hook as a one-entry list."""
|
||||
seen: list[Any] = []
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
seen.append(entries)
|
||||
yield [RemoteFile("https://example.com/a", Path("/cache/a"))]
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": {"key": "value"}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
|
||||
assert seen == [[{"key": "value"}]]
|
||||
mock_download.assert_called_once()
|
||||
assert _downloaded(mock_download) == [
|
||||
RemoteFile("https://example.com/a", Path("/cache/a"))
|
||||
]
|
||||
|
||||
|
||||
def test_platform_entries_are_grouped_per_platform() -> None:
|
||||
"""Platform domains route entries to each platform module's hook."""
|
||||
seen_a: list[Any] = []
|
||||
seen_b: list[Any] = []
|
||||
|
||||
def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
seen_a.extend(entries)
|
||||
yield [RemoteFile("url-a", Path("/a"))]
|
||||
|
||||
def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
seen_b.extend(entries)
|
||||
yield [RemoteFile("url-b", Path("/b"))]
|
||||
|
||||
entries = [
|
||||
{"platform": "a", "n": 1},
|
||||
{"platform": "b", "n": 2},
|
||||
{"platform": "a", "n": 3},
|
||||
]
|
||||
_, mock_download = _run_step(
|
||||
{"image": entries},
|
||||
{"image": _component(is_platform=True)},
|
||||
platforms={
|
||||
("image", "a"): _component(prefetch=hook_a),
|
||||
("image", "b"): _component(prefetch=hook_b),
|
||||
},
|
||||
)
|
||||
|
||||
assert seen_a == [entries[0], entries[2]]
|
||||
assert seen_b == [entries[1]]
|
||||
assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [
|
||||
RemoteFile("url-a", Path("/a")),
|
||||
RemoteFile("url-b", Path("/b")),
|
||||
]
|
||||
|
||||
|
||||
def test_hook_failure_does_not_fail_validation(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A raising hook is logged and other hooks still prefetch."""
|
||||
|
||||
def bad_hook(entries: list[dict]) -> list[RemoteFile]:
|
||||
raise RuntimeError("garbage config")
|
||||
|
||||
def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
yield [RemoteFile("url", Path("/g"))]
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"bad": {"x": 1}, "good": {"y": 2}},
|
||||
{
|
||||
"bad": _component(prefetch=bad_hook),
|
||||
"good": _component(prefetch=good_hook),
|
||||
},
|
||||
)
|
||||
|
||||
assert "Remote file prefetch for bad failed" in caplog.text
|
||||
assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))]
|
||||
|
||||
|
||||
def test_stages_download_between_resumptions() -> None:
|
||||
"""Each yielded stage is downloaded before the generator resumes."""
|
||||
order: list[str] = []
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
order.append("stage1")
|
||||
yield [RemoteFile("css-url", Path("/css"))]
|
||||
order.append("stage2")
|
||||
yield [RemoteFile("ttf-url", Path("/ttf"))]
|
||||
|
||||
def record_download(items: Any, description: str) -> None:
|
||||
order.append(f"download:{[file.url for file in items]}")
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"font": {"f": 1}},
|
||||
{"font": _component(prefetch=hook)},
|
||||
download_side_effect=record_download,
|
||||
)
|
||||
|
||||
assert order == [
|
||||
"stage1",
|
||||
"download:['css-url']",
|
||||
"stage2",
|
||||
"download:['ttf-url']",
|
||||
]
|
||||
assert mock_download.call_count == 2
|
||||
|
||||
|
||||
def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An endless generator stops after the stage backstop."""
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
n = 0
|
||||
while True:
|
||||
yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))]
|
||||
n += 1
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
|
||||
assert mock_download.call_count == 10
|
||||
assert "stopped after" in caplog.text
|
||||
|
||||
|
||||
def test_mid_stage_failure_stops_only_that_hook(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A generator raising on a later stage does not affect other hooks."""
|
||||
|
||||
def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
yield [RemoteFile("first", Path("/first"))]
|
||||
raise RuntimeError("stage two exploded")
|
||||
|
||||
def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
yield [RemoteFile("one", Path("/one"))]
|
||||
yield [RemoteFile("two", Path("/two"))]
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"flaky": {"x": 1}, "steady": {"y": 2}},
|
||||
{
|
||||
"flaky": _component(prefetch=flaky_hook),
|
||||
"steady": _component(prefetch=steady_hook),
|
||||
},
|
||||
)
|
||||
|
||||
assert "Remote file prefetch for flaky failed" in caplog.text
|
||||
assert mock_download.call_count == 2
|
||||
assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))]
|
||||
|
||||
|
||||
def test_download_failure_is_swallowed() -> None:
|
||||
"""cv.Invalid from the batch download never escapes the step."""
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
yield [RemoteFile("url", Path("/p"))]
|
||||
|
||||
result, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
download_side_effect=cv.Invalid("download failed"),
|
||||
)
|
||||
|
||||
mock_download.assert_called_once()
|
||||
assert not result.errors
|
||||
|
||||
|
||||
def test_domains_without_hooks_do_not_download() -> None:
|
||||
"""Components without PREFETCH_FILES cause no download call."""
|
||||
_, mock_download = _run_step(
|
||||
{"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}},
|
||||
{"plain": _component()},
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_none_and_autoload_confs_are_skipped() -> None:
|
||||
"""None and AutoLoad confs never reach a hook."""
|
||||
hook = MagicMock()
|
||||
_, mock_download = _run_step(
|
||||
{"a": None, "b": core.AutoLoad()},
|
||||
{"a": _component(prefetch=hook), "b": _component(prefetch=hook)},
|
||||
)
|
||||
hook.assert_not_called()
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_non_dict_entries_are_ignored() -> None:
|
||||
"""Garbage entries never reach a component hook."""
|
||||
hook = MagicMock()
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": ["just-a-string", 42]},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
hook.assert_not_called()
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_platform_entries_without_platform_key_are_ignored() -> None:
|
||||
"""Entries with a missing or unknown platform never reach a hook."""
|
||||
_, mock_download = _run_step(
|
||||
{"image": [{"n": 1}, "garbage", {"platform": "unknown"}]},
|
||||
{"image": _component(is_platform=True)},
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_generator_still_alive_at_the_cap_is_warned_and_closed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A generator with a stage left at the cap is warned about and closed."""
|
||||
closed: list[bool] = []
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
try:
|
||||
for n in range(10):
|
||||
yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))]
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
|
||||
assert mock_download.call_count == 10
|
||||
assert "stopped after" in caplog.text
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_plain_iterable_hook_survives_the_cap(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A hook returning a plain list of batches cannot crash the backstop."""
|
||||
|
||||
def hook(entries: list[dict]) -> list[list[RemoteFile]]:
|
||||
return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)]
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
|
||||
assert mock_download.call_count == 10
|
||||
assert "stopped after" in caplog.text
|
||||
|
||||
|
||||
def test_domain_level_hook_on_platform_component() -> None:
|
||||
"""A hook on the platform component's domain module sees all entries."""
|
||||
seen: list[Any] = []
|
||||
|
||||
def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
seen.append(entries)
|
||||
yield [RemoteFile("domain-url", Path("/domain"))]
|
||||
|
||||
entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}]
|
||||
_, mock_download = _run_step(
|
||||
{"image": entries},
|
||||
{"image": _component(prefetch=domain_hook, is_platform=True)},
|
||||
)
|
||||
|
||||
assert seen == [entries]
|
||||
assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))]
|
||||
|
||||
|
||||
def test_generator_raising_on_close_is_contained(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A generator whose close() raises at the cap is logged, not crashed on."""
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
try:
|
||||
for n in range(10):
|
||||
yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))]
|
||||
except GeneratorExit:
|
||||
raise RuntimeError("close exploded") from None
|
||||
|
||||
_, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
)
|
||||
|
||||
assert mock_download.call_count == 10
|
||||
assert "stopped after" in caplog.text
|
||||
|
||||
|
||||
def test_unexpected_download_error_is_logged_visibly(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A broken batch downloader warns instead of silently disabling prefetch."""
|
||||
|
||||
def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]:
|
||||
yield [RemoteFile("url", Path("/p"))]
|
||||
|
||||
result, mock_download = _run_step(
|
||||
{"my_comp": {"x": 1}},
|
||||
{"my_comp": _component(prefetch=hook)},
|
||||
download_side_effect=TypeError("not a RemoteFile"),
|
||||
)
|
||||
|
||||
mock_download.assert_called_once()
|
||||
assert not result.errors
|
||||
assert "Remote file prefetch failed" in caplog.text
|
||||
@@ -1,5 +1,9 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import string
|
||||
from unittest.mock import patch
|
||||
|
||||
from hypothesis import example, given, settings
|
||||
from hypothesis.strategies import builds, integers, ip_addresses, one_of, text
|
||||
@@ -45,6 +49,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -929,7 +934,7 @@ def test_string_no_slash__slash_replaced_with_warning(
|
||||
actual = cv.string_no_slash(value)
|
||||
assert actual == expected
|
||||
assert "reserved as a URL path separator" in caplog.text
|
||||
assert "will become an error in ESPHome 2026.7.0" in caplog.text
|
||||
assert "will become an error in ESPHome 2027.7.0" in caplog.text
|
||||
|
||||
|
||||
def test_string_no_slash__long_string_allowed() -> None:
|
||||
@@ -1465,9 +1470,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:
|
||||
@@ -2423,6 +2460,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
|
||||
|
||||
@@ -2461,6 +2550,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2491,6 +2594,52 @@ def test_returning_lambda_no_return() -> None:
|
||||
cv.returning_lambda(Lambda("int x = 5;"))
|
||||
|
||||
|
||||
def test_returning_lambda_return_only_in_comment() -> None:
|
||||
with pytest.raises(Invalid, match="return statement"):
|
||||
cv.returning_lambda(Lambda("// return 5;\nint x = 5;"))
|
||||
|
||||
|
||||
def test_returning_lambda_missing_semicolon_is_accepted() -> None:
|
||||
"""A forgotten semicolon is left for the C++ compiler to report."""
|
||||
assert isinstance(cv.returning_lambda(Lambda("return x")), Lambda)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("return 5;", True),
|
||||
("if (x) { return x; } return 0;", True),
|
||||
("if (x) return 1; else return 0;", True),
|
||||
("switch (x) { case 0: return 1; }", True),
|
||||
# a semicolon means code: any return keyword counts
|
||||
("return not x;", True),
|
||||
("return a and b;", True),
|
||||
("please return the sensor; then wait", True),
|
||||
# a forgotten semicolon is still lambda source; the compiler reports it
|
||||
("return id(x).state", True),
|
||||
("return x", True),
|
||||
("return 5", True),
|
||||
("return not x", True),
|
||||
# accepted: a one-word tail is indistinguishable from 'return x'
|
||||
("return soon", True),
|
||||
("Alert: return home", True),
|
||||
("static value", False),
|
||||
("no returns here", False),
|
||||
("the_return_value", False),
|
||||
# without a semicolon, prose is not lambda source
|
||||
("please return the item", False),
|
||||
("return to sender", False),
|
||||
("return a and b", False),
|
||||
# return only inside a comment is not a return statement
|
||||
("// return 5;\nint x = 5;", False),
|
||||
("/* return 5; */ int x = 5;", False),
|
||||
("return 5; // done", True),
|
||||
],
|
||||
)
|
||||
def test_looks_like_returning_lambda(value: str, expected: bool) -> None:
|
||||
assert cv.looks_like_returning_lambda(value) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dimensions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2855,11 +3004,63 @@ def test_require_esphome_version_ok() -> None:
|
||||
assert cv.require_esphome_version(1, 0, 0)("test") == "test"
|
||||
|
||||
|
||||
def test_require_esphome_version_accepts_version_object() -> None:
|
||||
"""The Version form matches require_framework_version's style."""
|
||||
assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test"
|
||||
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
|
||||
cv.require_esphome_version(cv.Version(9999, 0, 0))("test")
|
||||
|
||||
|
||||
def test_require_esphome_version_partial_ints_fail_at_call_site() -> None:
|
||||
"""Missing ints raise immediately instead of a TypeError inside the validator."""
|
||||
with pytest.raises(ValueError, match="needs a Version or"):
|
||||
cv.require_esphome_version(2026, 8)
|
||||
with pytest.raises(ValueError, match="needs a Version or"):
|
||||
cv.require_esphome_version(2026)
|
||||
|
||||
|
||||
def test_require_esphome_version_too_old() -> None:
|
||||
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
|
||||
cv.require_esphome_version(9999, 0, 0)("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"])
|
||||
def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None:
|
||||
"""A dev or beta build of the required version satisfies it.
|
||||
|
||||
Pins the behavior of the old tuple comparison that dropped the
|
||||
suffix, now expressed through Version ordering where the extra field
|
||||
only breaks ties upward.
|
||||
"""
|
||||
with patch.object(cv, "ESPHOME_VERSION", current):
|
||||
assert cv.require_esphome_version(2026, 8, 0)("test") == "test"
|
||||
|
||||
|
||||
def test_require_esphome_version_older_prerelease_fails() -> None:
|
||||
with (
|
||||
patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"),
|
||||
pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"),
|
||||
):
|
||||
cv.require_esphome_version(2026, 8, 0)("test")
|
||||
|
||||
|
||||
def test_parse_esphome_version_deprecated_shim(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The removed helper still works for external components and warns."""
|
||||
from esphome import const, util
|
||||
|
||||
with (
|
||||
patch.object(const, "__version__", "2026.9.0-dev"),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert cv.parse_esphome_version() == (2026, 9, 0)
|
||||
assert cv.parse_esphome_version() < (9999, 0, 0)
|
||||
assert "parse_esphome_version() is deprecated" in caplog.text
|
||||
# Both historical import paths resolve to the same function
|
||||
assert cv.parse_esphome_version is util.parse_esphome_version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# suppress_invalid / validate_source_shorthand / rename_key
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2909,3 +3110,178 @@ 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")
|
||||
|
||||
|
||||
def test_require_platformio_toolchain() -> None:
|
||||
"""Platforms with only the PlatformIO backend reject other toolchains."""
|
||||
validator = cv.require_platformio_toolchain("RP2")
|
||||
CORE.toolchain = None
|
||||
config: dict = {}
|
||||
assert validator(config) is config
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
|
||||
validator(config)
|
||||
|
||||
|
||||
def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
|
||||
"""Calling the check before resolution fails naming the ordering bug,
|
||||
not a user-facing unsupported-toolchain error."""
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
|
||||
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "minimal_config"),
|
||||
[
|
||||
("host", {}),
|
||||
("rp2", {"board": "rpipicow"}),
|
||||
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
|
||||
("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}),
|
||||
("ln882x", {"board": "generic-ln882h"}),
|
||||
# The legacy stub platform must reject too, not just the chip families
|
||||
("libretiny", {}),
|
||||
],
|
||||
)
|
||||
def test_every_platformio_only_platform_rejects_arduino_toolchain(
|
||||
platform: str, minimal_config: dict
|
||||
) -> None:
|
||||
"""A platform that cannot serve a CLI toolchain rejects it at validation."""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
module.CONFIG_SCHEMA(dict(minimal_config))
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from hypothesis import given
|
||||
import pytest
|
||||
from strategies import mac_addr_strings
|
||||
|
||||
from esphome import const, core
|
||||
from tests.unit_tests.strategies import mac_addr_strings
|
||||
|
||||
|
||||
class TestHexInt:
|
||||
@@ -213,6 +215,31 @@ class TestLambda:
|
||||
|
||||
assert str(target) is value.value
|
||||
|
||||
def test_init__expression_initializer(self):
|
||||
from esphome.cpp_generator import RawExpression
|
||||
|
||||
target = core.Lambda(RawExpression("foo()"))
|
||||
|
||||
assert target.value == "foo();"
|
||||
|
||||
def test_init__other_initializer(self):
|
||||
target = core.Lambda(123)
|
||||
|
||||
assert target.value == 123
|
||||
|
||||
def test_init_from_str_does_not_import_codegen(self):
|
||||
"""The validated-config cache revives Lambdas on the upload fast path."""
|
||||
# sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it.
|
||||
check = (
|
||||
"import sys; from esphome.core import Lambda; "
|
||||
"Lambda('return 1;'); "
|
||||
"sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", check], capture_output=True, text=True, check=False
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_parts(self):
|
||||
target = core.Lambda(SAMPLE_LAMBDA.strip())
|
||||
|
||||
@@ -931,6 +958,24 @@ class TestEsphomeCore:
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_toolchain_sdk_nrf is False
|
||||
|
||||
def test_using_toolchain_arduino(self, target):
|
||||
"""A toolchain choice, distinct from the arduino target framework."""
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_toolchain_arduino is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_toolchain_arduino is False
|
||||
|
||||
def test_using_native_toolchain(self, target):
|
||||
"""True exactly for the toolchains that never read platformio.ini."""
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_native_toolchain is False
|
||||
target.toolchain = const.Toolchain.SDK_NRF
|
||||
assert target.using_native_toolchain is False
|
||||
|
||||
def test_add_library__extracts_short_name_from_path(self, target):
|
||||
"""Test add_library extracts short name from library paths like owner/lib."""
|
||||
target.data[const.KEY_CORE] = {
|
||||
@@ -963,3 +1008,35 @@ class TestEsphomeCore:
|
||||
)
|
||||
# The unflag is still recorded either way.
|
||||
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
|
||||
|
||||
def test_add_cmake_arg(self, target) -> None:
|
||||
target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd")
|
||||
assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"}
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"])
|
||||
def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None:
|
||||
with pytest.raises(ValueError, match="Invalid CMake arg name"):
|
||||
target.add_cmake_arg(name, "value")
|
||||
|
||||
@pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"])
|
||||
def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None:
|
||||
"""Whitespace and quotes are rejected (the PlatformIO backend passes
|
||||
args as one space-joined string, which would split such a value), and
|
||||
so is '$' (expanded differently by CMake and PlatformIO)."""
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
target.add_cmake_arg("MY_ARG", value)
|
||||
|
||||
def test_add_cmake_arg__warns_on_overwrite(
|
||||
self, target, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Re-registering with a different value is last-writer-wins; warn so
|
||||
the silently dropped value is diagnosable."""
|
||||
target.add_cmake_arg("MY_ARG", "one")
|
||||
target.add_cmake_arg("MY_ARG", "one")
|
||||
assert "overwriting" not in caplog.text
|
||||
|
||||
target.add_cmake_arg("MY_ARG", "two")
|
||||
assert (
|
||||
"CMake arg MY_ARG already set to one; overwriting with two" in caplog.text
|
||||
)
|
||||
assert target.cmake_args == {"MY_ARG": "two"}
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from esphome import const, cpp_helpers as ch
|
||||
from esphome.core import CoroPriority, coroutine_with_priority
|
||||
from esphome.cpp_helpers import ComponentSourcePool, register_component_source
|
||||
|
||||
|
||||
@@ -167,3 +168,53 @@ def test_register_component_source_overflow_suppressed_in_testing_mode(
|
||||
idx = register_component_source("overflow_component")
|
||||
assert idx == 0
|
||||
assert "Too many unique component source names" not in caplog.text
|
||||
|
||||
|
||||
def _define_value(name: str) -> str | None:
|
||||
for define in ch.CORE.defines:
|
||||
if define.name == name:
|
||||
# Values are codegen expressions (IntLiteral); compare rendered.
|
||||
return str(define.value)
|
||||
return None
|
||||
|
||||
|
||||
def test_slot_counter_emits_requested_count() -> None:
|
||||
"""Each request bumps the count; the self-scheduled FINAL job emits it."""
|
||||
request = ch.slot_counter("TEST_SLOT_COUNT")
|
||||
request()
|
||||
request()
|
||||
ch.CORE.flush_tasks()
|
||||
assert _define_value("TEST_SLOT_COUNT") == "2"
|
||||
|
||||
|
||||
def test_slot_counter_without_requests_emits_nothing() -> None:
|
||||
"""No requests, no job, no define — the guarded storage compiles out."""
|
||||
ch.slot_counter("TEST_SLOT_COUNT_UNUSED")
|
||||
ch.CORE.flush_tasks()
|
||||
assert _define_value("TEST_SLOT_COUNT_UNUSED") is None
|
||||
|
||||
|
||||
def test_slot_counter_request_from_final_job_still_emits() -> None:
|
||||
"""The FIRST request for a define may come from a FINAL job: its emit job
|
||||
is scheduled mid-drain and flush_tasks() loops until the heap is empty.
|
||||
Later requests do not get this guarantee — see the companion test."""
|
||||
request = ch.slot_counter("TEST_SLOT_COUNT_LATE")
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def late_requester() -> None:
|
||||
request()
|
||||
|
||||
ch.CORE.add_job(late_requester)
|
||||
ch.CORE.flush_tasks()
|
||||
assert _define_value("TEST_SLOT_COUNT_LATE") == "1"
|
||||
|
||||
|
||||
def test_slot_counter_request_after_emit_raises() -> None:
|
||||
"""The boundary of FINAL-time requests: once the define was emitted, a
|
||||
further request would silently undersize the storage, so it fails loudly."""
|
||||
request = ch.slot_counter("TEST_SLOT_COUNT_TOO_LATE")
|
||||
request()
|
||||
ch.CORE.flush_tasks()
|
||||
assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1"
|
||||
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"):
|
||||
request()
|
||||
|
||||
@@ -10,8 +10,10 @@ during the adoption flow and depend on the output's ``esphome.name``
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests as req
|
||||
import yaml as pyyaml
|
||||
|
||||
from esphome.components.dashboard_import import import_config
|
||||
@@ -201,3 +203,56 @@ def test_import_refuses_to_overwrite_existing_yaml(tmp_path: Path) -> None:
|
||||
)
|
||||
# Original content survives unchanged.
|
||||
assert yaml_path.read_text() == "# user's hand-edited config\n"
|
||||
|
||||
|
||||
def _full_config_kwargs(yaml_path: Path) -> dict:
|
||||
return {
|
||||
"path": str(yaml_path),
|
||||
"name": "kitchen",
|
||||
"friendly_name": None,
|
||||
"project_name": "acme.kitchen-light",
|
||||
"import_url": "github://acme/firmware/kitchen.yaml@main?full_config",
|
||||
}
|
||||
|
||||
|
||||
def test_full_config_import_fetches_and_writes_contents(tmp_path: Path) -> None:
|
||||
yaml_path = tmp_path / "kitchen.yaml"
|
||||
resp = MagicMock(text="esphome:\n name: orig\n")
|
||||
with patch(
|
||||
"esphome.components.dashboard_import.http_request", return_value=resp
|
||||
) as mock_req:
|
||||
import_config(**_full_config_kwargs(yaml_path))
|
||||
assert yaml_path.read_text() == "esphome:\n name: orig\n"
|
||||
assert mock_req.call_args[0][0] == "GET"
|
||||
|
||||
|
||||
def test_full_config_import_retries_transient_errors(tmp_path: Path) -> None:
|
||||
"""The fetch goes through the shared retry policy: a transient network
|
||||
error is retried instead of failing the adoption immediately."""
|
||||
yaml_path = tmp_path / "kitchen.yaml"
|
||||
resp = MagicMock(text="esphome:\n name: orig\n")
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.dashboard_import.http_request",
|
||||
side_effect=[req.ConnectionError("reset"), resp],
|
||||
),
|
||||
patch("esphome.net_retry.time.sleep") as mock_sleep,
|
||||
):
|
||||
import_config(**_full_config_kwargs(yaml_path))
|
||||
assert yaml_path.exists()
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
|
||||
def test_full_config_import_wraps_permanent_errors_in_value_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""device-builder depends on the ValueError contract for fetch failures."""
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.side_effect = req.HTTPError(
|
||||
"404", response=MagicMock(status_code=404)
|
||||
)
|
||||
with (
|
||||
patch("esphome.components.dashboard_import.http_request", return_value=resp),
|
||||
pytest.raises(ValueError, match="Error while fetching"),
|
||||
):
|
||||
import_config(**_full_config_kwargs(tmp_path / "kitchen.yaml"))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Platform get_download_types contract for never-built configs.
|
||||
|
||||
Wizard-written and upload/logs-fallback sidecars record no
|
||||
firmware_bin_path; the download panel must get an empty list for them,
|
||||
not entries pointing at files that were never built.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"]
|
||||
|
||||
|
||||
def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]:
|
||||
return import_module(f"esphome.components.{platform}").get_download_types(storage)
|
||||
|
||||
|
||||
def _wizard_storage() -> StorageJSON:
|
||||
return StorageJSON.from_wizard(
|
||||
name="test_device",
|
||||
friendly_name="Test Device",
|
||||
address="test_device.local",
|
||||
platform="ESP32",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", PLATFORMS)
|
||||
def test_no_firmware_path_yields_no_downloads(platform: str) -> None:
|
||||
"""No recorded firmware path means nothing was built; no downloads."""
|
||||
assert _download_types(platform, _wizard_storage()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", PLATFORMS)
|
||||
def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None:
|
||||
"""With a firmware path recorded, every platform offers entries in
|
||||
the documented title/description/file/download shape."""
|
||||
storage = _wizard_storage()
|
||||
storage.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
|
||||
types = _download_types(platform, storage)
|
||||
|
||||
assert types
|
||||
assert all(
|
||||
{"title", "description", "file", "download"} <= entry.keys() for entry in types
|
||||
)
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import clang_tidy
|
||||
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -64,3 +67,35 @@ def test_setup_core_sets_arduino_env(
|
||||
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
|
||||
|
||||
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project(tmp_path) -> None:
|
||||
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": str(tmp_path / "main" / "tidy.cpp"),
|
||||
"command": "/tc/xtensa-esp32-elf-g++ -DUSE_ESP32 "
|
||||
f"-I{tmp_path}/inc -c main/tidy.cpp -o tidy.o",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"esphome.espidf.clang_tidy.get_toolchain_includes", return_value=["/tc/inc"]
|
||||
):
|
||||
data = clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
assert data["cxx_path"] == "/tc/xtensa-esp32-elf-g++"
|
||||
assert data["defines"] == ["USE_ESP32"]
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc"]
|
||||
assert any(inc.endswith("/inc") for inc in data["includes"]["build"])
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps([]))
|
||||
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
|
||||
clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32 as esp32_module
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
@@ -15,21 +16,24 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, Library
|
||||
from esphome.espidf.component import (
|
||||
_emit_idf_component,
|
||||
generate_cmakelists_txt,
|
||||
generate_idf_component_yml,
|
||||
generate_idf_components,
|
||||
)
|
||||
import esphome.platformio.library
|
||||
from esphome.platformio.library import (
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
ConvertedLibrary as IDFComponent,
|
||||
GitSource,
|
||||
URLSource,
|
||||
_node_key,
|
||||
_normalize_dependencies,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_resolve_registry_version,
|
||||
collect_filtered_files,
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
split_list_by_condition,
|
||||
)
|
||||
|
||||
@@ -86,6 +90,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"]
|
||||
|
||||
@@ -112,6 +158,62 @@ def test_generate_cmakelists_txt_basic(tmp_component):
|
||||
assert "main.c" in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_external_source_uses_absolute_paths(
|
||||
tmp_component, tmp_path
|
||||
):
|
||||
# A local library's sources live outside the component dir (source_path),
|
||||
# so SRCS and INCLUDE_DIRS must be emitted as absolute paths into it.
|
||||
source = tmp_path / "user_lib"
|
||||
(source / "src").mkdir(parents=True)
|
||||
(source / "include").mkdir()
|
||||
(source / "src" / "thing.cpp").write_text("int t;")
|
||||
tmp_component.source_path = source
|
||||
tmp_component.data = {}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
|
||||
abs_src = str((source / "src" / "thing.cpp").resolve()).replace("\\", "/")
|
||||
abs_inc = str((source / "include").resolve()).replace("\\", "/")
|
||||
assert abs_src in content
|
||||
assert abs_inc in content
|
||||
# Nothing was copied into the component dir.
|
||||
assert not (tmp_component.path / "src").exists()
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_external_source_absolutises_link_dirs(
|
||||
tmp_component, tmp_path
|
||||
):
|
||||
# A local library's relative -L path must be made absolute against its own
|
||||
# directory so it resolves from the component cache dir.
|
||||
source = tmp_path / "user_lib"
|
||||
(source / "src").mkdir(parents=True)
|
||||
(source / "src" / "thing.cpp").write_text("int t;")
|
||||
(source / "libs").mkdir()
|
||||
tmp_component.source_path = source
|
||||
tmp_component.data = {"build": {"flags": ["-Llibs"]}}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
|
||||
abs_lib = str((source / "libs").resolve()).replace("\\", "/")
|
||||
assert "target_link_directories" in content
|
||||
assert abs_lib in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_external_source_root_srcdir(tmp_component, tmp_path):
|
||||
# An external source with files at its root (no src/ or include/ dir):
|
||||
# the src-dir search falls through to "." and the missing include dirs are
|
||||
# filtered out.
|
||||
source = tmp_path / "flat_lib"
|
||||
source.mkdir()
|
||||
(source / "thing.cpp").write_text("int t;")
|
||||
tmp_component.source_path = source
|
||||
tmp_component.data = {}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
|
||||
assert str((source / "thing.cpp").resolve()).replace("\\", "/") in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
@@ -126,30 +228,130 @@ 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_escapes_embedded_quotes(tmp_component):
|
||||
"""A define value carrying a literal quote survives into CMake as an
|
||||
escaped quote, not a prematurely-terminated string."""
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
# shlex keeps the backslash-escaped quotes as literal characters
|
||||
tmp_component.data = {"build": {"flags": ['-DMSG=\\"hi\\"']}}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert '"-DMSG=\\"hi\\""' in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component):
|
||||
"""Captured extra-script LINKFLAGS come out as target_link_options, not
|
||||
compile options where they would be silently ineffective."""
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
|
||||
tmp_component.data = {
|
||||
ESPHOME_DATA_KEY: {ESPHOME_DATA_LINK_FLAGS_KEY: ["-Wl,--gc-sections"]}
|
||||
}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert (
|
||||
'target_link_options(${COMPONENT_LIB} INTERFACE\n "-Wl,--gc-sections"\n)'
|
||||
in content
|
||||
)
|
||||
assert "target_compile_options" not 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:
|
||||
@@ -172,6 +374,18 @@ def test_generate_idf_component_yml_basic(tmp_component):
|
||||
assert result == "description: test\nrepository: http://aaa\n"
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_tolerates_malformed_metadata(tmp_component):
|
||||
"""A string repository is the URL itself; junk shapes drop instead of
|
||||
crashing on a third-party manifest."""
|
||||
tmp_component.data = {"description": "test", "repository": "http://aaa"}
|
||||
assert (
|
||||
generate_idf_component_yml(tmp_component)
|
||||
== "description: test\nrepository: http://aaa\n"
|
||||
)
|
||||
tmp_component.data = {"description": {"en": "x"}, "repository": 123}
|
||||
assert generate_idf_component_yml(tmp_component) == "{}\n"
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
|
||||
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
|
||||
dep.path = tmp_path / "dep"
|
||||
@@ -202,133 +416,11 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component):
|
||||
generate_idf_component_yml(tmp_component)
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == [str(Path("src") / "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert ("BAR", "1") in result.cppdefines
|
||||
assert "FOO" in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in flags
|
||||
assert "-lalgobsec" in flags
|
||||
assert "-DFOO" in flags
|
||||
assert "-DBAR=1" in flags
|
||||
assert "-Wl,--gc-sections" in flags
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert flags == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert flags == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
from esphome.espidf.extra_script import run_extra_script
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
|
||||
from esphome.components import esp32 as esp32_module
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_parse_library_json(tmp_path):
|
||||
f = tmp_path / "library.json"
|
||||
f.write_text(json.dumps({"name": "test"}))
|
||||
|
||||
result = _parse_library_json(f)
|
||||
result = parse_library_json(f)
|
||||
assert result["name"] == "test"
|
||||
|
||||
|
||||
@@ -343,7 +435,7 @@ empty=
|
||||
"""
|
||||
)
|
||||
|
||||
result = _parse_library_properties(f)
|
||||
result = parse_library_properties(f)
|
||||
|
||||
assert result["name"] == "Test"
|
||||
assert result["version"] == "1.0"
|
||||
@@ -351,54 +443,184 @@ empty=
|
||||
|
||||
|
||||
def test_node_key_git_with_ref():
|
||||
key, is_git, locator = _node_key(
|
||||
key, kind, locator = _node_key(
|
||||
"name", None, "https://github.com/foo/bar.git#v1.2.3"
|
||||
)
|
||||
assert key == "foo/bar"
|
||||
assert is_git is True
|
||||
assert kind == "git"
|
||||
assert locator == ("https://github.com/foo/bar.git", "v1.2.3")
|
||||
|
||||
|
||||
def test_node_key_git_branch_ref():
|
||||
key, is_git, locator = _node_key(
|
||||
key, kind, locator = _node_key(
|
||||
"name", None, "https://github.com/foo/bar.git#some-branch"
|
||||
)
|
||||
assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch")
|
||||
assert (key, kind, locator[1]) == ("foo/bar", "git", "some-branch")
|
||||
|
||||
|
||||
def test_node_key_git_no_ref():
|
||||
_key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git")
|
||||
assert is_git is True
|
||||
_key, kind, locator = _node_key("name", None, "https://github.com/foo/bar.git")
|
||||
assert kind == "git"
|
||||
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, kind, locator = _node_key("https://github.com/pstolarz/OneWireNg", None, None)
|
||||
assert key == "pstolarz/OneWireNg"
|
||||
assert kind == "git"
|
||||
assert locator == ("https://github.com/pstolarz/OneWireNg", None)
|
||||
|
||||
|
||||
def test_node_key_url_in_name_with_ref():
|
||||
key, kind, locator = _node_key("https://github.com/foo/bar.git#v1.2.3", None, None)
|
||||
assert (key, kind, locator) == (
|
||||
"foo/bar",
|
||||
"git",
|
||||
("https://github.com/foo/bar.git", "v1.2.3"),
|
||||
)
|
||||
|
||||
|
||||
def test_node_key_url_in_name_git_plus_prefix():
|
||||
key, kind, locator = _node_key("git+https://github.com/foo/bar", None, None)
|
||||
assert (key, kind, locator) == (
|
||||
"foo/bar",
|
||||
"git",
|
||||
("https://github.com/foo/bar", None),
|
||||
)
|
||||
|
||||
|
||||
def test_node_key_git_plus_prefix_in_repository():
|
||||
_key, kind, locator = _node_key("name", None, "git+https://github.com/foo/bar")
|
||||
assert (kind, locator) == ("git", ("https://github.com/foo/bar", None))
|
||||
|
||||
|
||||
def test_node_key_custom_name_equals_url_is_git():
|
||||
key, kind, locator = _node_key(
|
||||
"OneWireNg=https://github.com/pstolarz/OneWireNg", None, None
|
||||
)
|
||||
assert (key, kind, locator) == (
|
||||
"pstolarz/OneWireNg",
|
||||
"git",
|
||||
("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, kind, locator = _node_key("https://host/x/y.git?ref=main", None, None)
|
||||
assert (key, kind, locator) == (
|
||||
"x/y",
|
||||
"git",
|
||||
("https://host/x/y.git?ref=main", None),
|
||||
)
|
||||
|
||||
|
||||
def test_node_key_file_url_in_repository_is_local():
|
||||
# A plain file:// entry (PlatformIO's spelling for a local library folder)
|
||||
# resolves as a local directory, keeping the custom name as the key. The
|
||||
# path is the OS-native form of the URL (backslashes on Windows).
|
||||
key, kind, (path, ref) = _node_key(
|
||||
"TeslaBLE", None, "file:///config/esphome/lib_dev"
|
||||
)
|
||||
assert (key, kind, ref) == ("TeslaBLE", "local", None)
|
||||
assert Path(path) == Path("/config/esphome/lib_dev")
|
||||
|
||||
|
||||
def test_node_key_bare_file_url_is_local_named_for_dir():
|
||||
# Without a custom name the directory's own name becomes the key.
|
||||
key, kind, (path, ref) = _node_key(None, None, "file:///opt/mylib")
|
||||
assert (key, kind, ref) == ("mylib", "local", None)
|
||||
assert Path(path) == Path("/opt/mylib")
|
||||
|
||||
|
||||
def test_node_key_custom_name_equals_file_url_is_local():
|
||||
key, kind, (path, ref) = _node_key("Foo=file:///opt/mylib", None, None)
|
||||
assert (key, kind, ref) == ("Foo", "local", None)
|
||||
assert Path(path) == Path("/opt/mylib")
|
||||
|
||||
|
||||
def test_node_key_file_url_localhost_host_is_local():
|
||||
# A localhost host is ignored; only the path identifies the directory.
|
||||
key, kind, (path, ref) = _node_key(None, None, "file://localhost/opt/mylib")
|
||||
assert (key, kind, ref) == ("mylib", "local", None)
|
||||
assert Path(path) == Path("/opt/mylib")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url", ["file://server/share/lib", "file://lib_dev", "file://../mylib"]
|
||||
)
|
||||
def test_node_key_file_url_with_host_rejected(url: str) -> None:
|
||||
# A real host, or a relative path whose first segment parses as the host,
|
||||
# is rejected rather than silently resolved to the wrong directory.
|
||||
with pytest.raises(RuntimeError, match="Unsupported host in file://"):
|
||||
_node_key(None, None, url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:lib_dev", "file:./lib", "file:///"])
|
||||
def test_node_key_file_url_must_be_absolute(url: str) -> None:
|
||||
# A relative path (no host, e.g. file:lib_dev) or a bare root (file:///)
|
||||
# is rejected rather than resolved against the cwd or yielding an empty name.
|
||||
with pytest.raises(RuntimeError, match="must be an absolute"):
|
||||
_node_key(None, None, url)
|
||||
|
||||
|
||||
def test_node_key_git_plus_file_url_stays_git():
|
||||
# git+file:// is an explicit local git repo, not a plain directory.
|
||||
_key, kind, locator = _node_key("X", None, "git+file:///srv/foo.git")
|
||||
assert kind == "git"
|
||||
assert locator == ("file:///srv/foo.git", 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, kind, locator = _node_key("FOO=BAR", "1.0", None)
|
||||
assert (key, kind, locator) == ("FOO=BAR", "registry", (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, kind, _locator = _node_key("bar", "https://github.com/foo/bar", None)
|
||||
assert (key, kind) == ("bar", "registry")
|
||||
|
||||
|
||||
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"))
|
||||
key, kind, locator = _node_key("foo/bar", "^1.0.0", None)
|
||||
assert (key, kind, locator) == ("foo/bar", "registry", ("foo", "bar"))
|
||||
|
||||
|
||||
def test_node_key_registry_bare_name():
|
||||
key, is_git, locator = _node_key("bar", "1.0", None)
|
||||
assert (key, is_git, locator) == ("bar", False, (None, "bar"))
|
||||
key, kind, locator = _node_key("bar", "1.0", None)
|
||||
assert (key, kind, locator) == ("bar", "registry", (None, "bar"))
|
||||
|
||||
|
||||
def test_normalize_dependencies_none():
|
||||
assert _normalize_dependencies(None) == []
|
||||
assert normalize_dependencies(None) == []
|
||||
|
||||
|
||||
def test_normalize_dependencies_list_form():
|
||||
deps = [{"name": "foo", "version": "1.0"}]
|
||||
assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
|
||||
assert normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
|
||||
|
||||
|
||||
def test_normalize_dependencies_dict_form():
|
||||
out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
|
||||
out = normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
|
||||
assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out
|
||||
assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out
|
||||
|
||||
|
||||
def test_normalize_dependencies_dict_form_nested_spec():
|
||||
out = _normalize_dependencies(
|
||||
out = normalize_dependencies(
|
||||
{"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}}
|
||||
)
|
||||
assert out == [
|
||||
@@ -438,7 +660,7 @@ def _patch_registry(monkeypatch, versions):
|
||||
|
||||
def test_resolve_registry_version_intersects_constraints(monkeypatch):
|
||||
_patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"])
|
||||
owner, name, version, url = _resolve_registry_version(
|
||||
owner, name, version, url, _size = _resolve_registry_version(
|
||||
"esphome", "libsodium", {"==1.10021.0", "^1.10018.1"}
|
||||
)
|
||||
assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0")
|
||||
@@ -447,7 +669,9 @@ def test_resolve_registry_version_intersects_constraints(monkeypatch):
|
||||
|
||||
def test_resolve_registry_version_picks_highest_satisfying(monkeypatch):
|
||||
_patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"])
|
||||
_owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"})
|
||||
_owner, _name, version, _url, _size = _resolve_registry_version(
|
||||
"o", "p", {"^1.0.0"}
|
||||
)
|
||||
assert version == "1.5.0"
|
||||
|
||||
|
||||
@@ -497,7 +721,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
|
||||
resolve_calls.append(pkgname)
|
||||
captured[f"{owner}/{pkgname}"] = set(requirements)
|
||||
version = "1.10021.0" if pkgname == "C" else "1.0.0"
|
||||
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
|
||||
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.platformio.library, "_resolve_registry_version", fake_resolve
|
||||
@@ -556,7 +780,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
|
||||
|
||||
def fake_resolve(owner, pkgname, requirements):
|
||||
resolve_calls.append(pkgname)
|
||||
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
|
||||
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.platformio.library, "_resolve_registry_version", fake_resolve
|
||||
@@ -612,6 +836,7 @@ def test_generate_idf_components_handles_dependency_cycle(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -669,6 +894,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -705,6 +931,7 @@ def test_generate_idf_components_missing_manifest_raises(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -749,6 +976,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -782,6 +1010,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -818,6 +1047,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -893,3 +1123,33 @@ def test_idf_component_download_passes_salt() -> None:
|
||||
"owner/name", force=True, salt="abcd1234", namespace="idf"
|
||||
)
|
||||
assert c.path == Path("/converted/owner/name")
|
||||
|
||||
|
||||
def test_emit_idf_component_wires_esp32_target(tmp_path, monkeypatch):
|
||||
"""Emitting a component resolves the esp32 variant into the shared
|
||||
extraScript helper."""
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n")
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
_emit_idf_component(c)
|
||||
assert c.data["build"]["flags"] == ["-lesp32"]
|
||||
|
||||
|
||||
def test_build_flags_dangling_flag_does_not_cross_entries(
|
||||
tmp_path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Each entry is lexed independently, as ParseFlags does: a dangling -I ending one
|
||||
entry warns instead of absorbing the next entry's first token."""
|
||||
(tmp_path / "src").mkdir()
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"flags": ["-Wall -I", "-DFOO=1"]}}
|
||||
content = generate_cmakelists_txt(c)
|
||||
assert "FOO=1" in content
|
||||
assert "-I-DFOO" not in content
|
||||
assert "Ignoring trailing '-I'" in caplog.text
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,264 +0,0 @@
|
||||
"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import idedata
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# _parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for esphome.espidf.runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import runner
|
||||
|
||||
# A flushing runner delivers the first line in well under a second; this is
|
||||
# only ever waited out when the shim has gone back to buffering, so keep it
|
||||
# just long enough to cover interpreter startup on a loaded CI machine.
|
||||
FIRST_LINE_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _prepare_main(
|
||||
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
|
||||
) -> tuple[io.BytesIO, io.TextIOWrapper]:
|
||||
"""Point ``runner.main()`` at *probe* with a buffered fake stdout.
|
||||
|
||||
``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and
|
||||
``os.get_terminal_size``; every one of those is monkeypatched so it is
|
||||
put back afterwards. The fake stdout is block buffered like a pipe, so
|
||||
the caller can tell whether the shim flushed. The wrapper comes back with
|
||||
the buffer because dropping it would close the buffer underneath us.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
|
||||
|
||||
monkeypatch.setattr(sys, "path", list(sys.path))
|
||||
monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args])
|
||||
monkeypatch.setattr(sys, "stdout", stream)
|
||||
monkeypatch.setattr(sys, "stderr", stream)
|
||||
monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size)
|
||||
|
||||
return buf, stream
|
||||
|
||||
|
||||
def _run_main(
|
||||
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
|
||||
) -> tuple[io.BytesIO, io.TextIOWrapper]:
|
||||
"""Run ``runner.main()`` against *probe* and expect a clean exit."""
|
||||
buf, stream = _prepare_main(monkeypatch, probe, *args)
|
||||
assert runner.main() == 0
|
||||
return buf, stream
|
||||
|
||||
|
||||
def test_main_filters_noise_and_flushes_each_write(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""Useful lines reach the stream right away; noisy ones are dropped."""
|
||||
buf, _stream = _run_main(
|
||||
monkeypatch, fixture_path / "espidf" / "filtering_probe.py"
|
||||
)
|
||||
|
||||
# Read before any flush of our own: the shim has to have flushed.
|
||||
output = buf.getvalue().decode("utf-8")
|
||||
|
||||
assert "Compiling main.cpp\n" in output
|
||||
assert "[2/9] Building C object\n" in output
|
||||
# Matched by FILTER_IDF_LINES, so they never leave the runner.
|
||||
assert "Project build complete." not in output
|
||||
assert "-- Component paths:" not in output
|
||||
# Held back until the end because no terminator arrived.
|
||||
assert output.endswith("still going\n")
|
||||
|
||||
|
||||
def test_main_keeps_output_after_a_form_feed(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""A form feed is text, not a line break, so nothing after it is lost."""
|
||||
buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py")
|
||||
|
||||
assert buf.getvalue().decode("utf-8") == (
|
||||
"Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n"
|
||||
)
|
||||
|
||||
|
||||
def test_main_drains_a_partial_line_when_the_build_dies(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""A build that stops mid line must still show that line.
|
||||
|
||||
This is the whole point of draining: the message explaining why the
|
||||
build failed is exactly the one most likely to arrive without a
|
||||
trailing newline.
|
||||
"""
|
||||
buf, _stream = _prepare_main(
|
||||
monkeypatch, fixture_path / "espidf" / "crashing_probe.py"
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
runner.main()
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n"
|
||||
|
||||
|
||||
def test_main_reports_rather_than_raises_when_draining_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fixture_path: Path,
|
||||
capfd: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A stream that closed under us must not crash the runner's cleanup.
|
||||
|
||||
The drain runs from a ``finally``, so an exception there would replace
|
||||
whatever exit code the build was carrying back.
|
||||
"""
|
||||
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
|
||||
|
||||
assert runner.main() == 0
|
||||
reported = capfd.readouterr().err
|
||||
assert "Could not write out remaining output" in reported
|
||||
# The held line has to come along; the stream it was meant for is gone.
|
||||
assert "partial before close" in reported
|
||||
|
||||
|
||||
def test_main_survives_a_drain_failure_with_nowhere_to_report_it(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""With no real stderr to report to, cleanup still must not raise.
|
||||
|
||||
``sys.__stderr__`` is None on some interpreters, and ``print(file=None)``
|
||||
falls back to ``sys.stdout``, which here is the shim wrapping the stream
|
||||
that just failed.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "__stderr__", None)
|
||||
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
|
||||
|
||||
assert runner.main() == 0
|
||||
|
||||
|
||||
def test_main_still_filters_a_drained_partial_line(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""Releasing a held line does not smuggle noise past the filter."""
|
||||
buf, _stream = _run_main(
|
||||
monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py"
|
||||
)
|
||||
|
||||
assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n"
|
||||
|
||||
|
||||
def test_main_keeps_everything_in_verbose_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""``-v`` turns the filter off so the noisy lines survive."""
|
||||
buf, _stream = _run_main(
|
||||
monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v"
|
||||
)
|
||||
|
||||
output = buf.getvalue().decode("utf-8")
|
||||
|
||||
assert "Project build complete.\n" in output
|
||||
assert "-- Component paths: /a /b /c\n" in output
|
||||
# With no filter there is no line buffering, so the partial line goes
|
||||
# straight through as well.
|
||||
assert output.endswith("still going")
|
||||
|
||||
|
||||
def test_runner_streams_output_before_the_build_finishes(
|
||||
fixture_path: Path, probe_env: dict[str, str]
|
||||
) -> None:
|
||||
"""The runner must flush, or a dashboard build looks frozen.
|
||||
|
||||
``toolchain.py`` spawns the runner as a plain script with no ``-u``, and
|
||||
hands it a pipe when esphome itself is running under the dashboard. A
|
||||
pipe is block buffered, so without a flush in the shim's ``write()`` the
|
||||
output sits in the child until 8 KiB piles up or the build ends.
|
||||
"""
|
||||
runner_py = Path(runner.__file__)
|
||||
probe = fixture_path / "espidf" / "streaming_probe.py"
|
||||
|
||||
with subprocess.Popen(
|
||||
[sys.executable, str(runner_py), str(probe)],
|
||||
stdout=subprocess.PIPE,
|
||||
# Keep stderr: if the runner dies on startup, its traceback is the
|
||||
# only clue about why no line showed up.
|
||||
stderr=subprocess.PIPE,
|
||||
env=probe_env,
|
||||
text=True,
|
||||
) as proc:
|
||||
assert proc.stdout is not None
|
||||
assert proc.stderr is not None
|
||||
first_line: list[str] = []
|
||||
reader = threading.Thread(
|
||||
target=lambda: first_line.append(proc.stdout.readline()), daemon=True
|
||||
)
|
||||
try:
|
||||
reader.start()
|
||||
reader.join(FIRST_LINE_TIMEOUT)
|
||||
still_running = proc.poll() is None
|
||||
|
||||
# The probe sleeps for a minute after writing, so reaching us at
|
||||
# all means the line was flushed rather than released at exit.
|
||||
assert first_line == ["Compiling main.cpp\n"], (
|
||||
f"runner stderr: {'' if still_running else proc.stderr.read()}"
|
||||
)
|
||||
assert still_running
|
||||
finally:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
# Join before leaving the block, so the reader is done rather than
|
||||
# racing ``Popen`` closing the pipe under it.
|
||||
reader.join(1.0)
|
||||
@@ -2,13 +2,24 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
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 +58,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 +69,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]:
|
||||
@@ -70,6 +102,33 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]:
|
||||
return compile_commands, cache
|
||||
|
||||
|
||||
def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None:
|
||||
"""A newer exclude_components.esphomeinternal stamp forces a reconfigure
|
||||
so components that leave the exclusion set get rediscovered."""
|
||||
CORE.build_path = setup_core
|
||||
build = setup_core / "build"
|
||||
(build / "config").mkdir(parents=True)
|
||||
(build / "config" / "sdkconfig.h").write_text("")
|
||||
cmakecache = build / "CMakeCache.txt"
|
||||
cmakecache.write_text("")
|
||||
(build / "build.ninja").write_text("")
|
||||
|
||||
with patch.object(CORE, "name", "test"):
|
||||
assert not toolchain.has_outdated_files()
|
||||
|
||||
stamp = setup_core / "exclude_components.esphomeinternal"
|
||||
stamp.write_text("unity")
|
||||
os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2)
|
||||
|
||||
assert toolchain.has_outdated_files()
|
||||
|
||||
# The flag must clear once the reference file is restamped (as
|
||||
# run_compile does after a successful discovery reconfigure);
|
||||
# otherwise every later build would repeat the discovery pass.
|
||||
os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2)
|
||||
assert not toolchain.has_outdated_files()
|
||||
|
||||
|
||||
def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None:
|
||||
"""No compile DB yet -> None (rather than an error)."""
|
||||
_setup_build(setup_core)
|
||||
@@ -83,7 +142,7 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
@@ -94,63 +153,6 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
|
||||
|
||||
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
"""A cache at least as new as the compile DB is reused without regenerating."""
|
||||
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") as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_not_called()
|
||||
assert result == {"cxx_path": "cached"}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "stale"}')
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache_mtime = cache.stat().st_mtime
|
||||
os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "fresh"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
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)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text("{not json")
|
||||
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={"cxx_path": "regen"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
"""The idedata exposes prog_path (the ELF) so consumers like build-action
|
||||
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
|
||||
@@ -159,7 +161,7 @@ def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
):
|
||||
result = toolchain.get_idedata()
|
||||
@@ -184,6 +186,473 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
|
||||
|
||||
|
||||
def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
|
||||
"""A PYTHONPATH from the parent environment must not reach idf.py.
|
||||
|
||||
It would override the IDF venv's isolation, shadowing its pinned
|
||||
packages and failing idf.py's dependency check.
|
||||
"""
|
||||
toolchain._cache().env.clear()
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"},
|
||||
):
|
||||
env = toolchain._get_idf_env(version="5.5.4")
|
||||
assert "PYTHONPATH" not in env
|
||||
|
||||
|
||||
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_restamps_cmakecache_after_discovery(setup_core: Path) -> None:
|
||||
"""After a successful discovery reconfigure the reference CMakeCache.txt
|
||||
is restamped; cmake does not rewrite it when only properties or plain
|
||||
variables change, so the staleness flag would otherwise never clear."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {}}
|
||||
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
|
||||
build_ninja = CORE.relative_build_path("build/build.ninja")
|
||||
cmakecache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmakecache.write_text("")
|
||||
build_ninja.write_text("")
|
||||
old = cmakecache.stat().st_mtime - 100
|
||||
os.utime(cmakecache, (old, old))
|
||||
os.utime(build_ninja, (old, old))
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project"),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=0),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
assert toolchain.run_compile(config, verbose=False) == 0
|
||||
|
||||
assert cmakecache.stat().st_mtime > old
|
||||
# build.ninja must not be older than the cache or ninja re-runs cmake
|
||||
assert build_ninja.stat().st_mtime >= cmakecache.stat().st_mtime
|
||||
|
||||
|
||||
def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
|
||||
"""A discovery pass that produced no CMakeCache.txt (nothing to restamp)
|
||||
still completes normally."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {}}
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project"),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=0),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
assert toolchain.run_compile(config, verbose=False) == 0
|
||||
|
||||
assert not CORE.relative_build_path("build/CMakeCache.txt").exists()
|
||||
|
||||
|
||||
def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""The full CMakeLists write is followed by a reconfigure (#18682); a
|
||||
failure there stops the build and leaves the cache unstamped."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {}}
|
||||
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
|
||||
cmakecache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmakecache.write_text("")
|
||||
old = cmakecache.stat().st_mtime - 100
|
||||
os.utime(cmakecache, (old, old))
|
||||
calls: list[tuple] = []
|
||||
reconfigures = 0
|
||||
|
||||
def record_write(minimal: bool = False, builtin_components=None) -> None:
|
||||
calls.append(("write_project", minimal))
|
||||
|
||||
def record_reconfigure() -> int:
|
||||
nonlocal reconfigures
|
||||
reconfigures += 1
|
||||
calls.append(("run_reconfigure",))
|
||||
return 1 if reconfigures == 2 else 0
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
|
||||
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0) as mock_build,
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
assert not CORE.testing_mode
|
||||
assert toolchain.run_compile(config, verbose=False) == 1
|
||||
|
||||
assert calls == [
|
||||
("write_project", True),
|
||||
("run_reconfigure",),
|
||||
("write_project", False),
|
||||
("run_reconfigure",),
|
||||
]
|
||||
mock_build.assert_not_called()
|
||||
assert cmakecache.stat().st_mtime == old
|
||||
|
||||
|
||||
def _record_compile_calls(
|
||||
cached: list[str] | None,
|
||||
saved: list[str] | None = None,
|
||||
reconfigure_rcs: tuple[int, ...] = (),
|
||||
cache_file: Path | None = None,
|
||||
) -> tuple[int, list[tuple]]:
|
||||
"""Run run_compile with a stubbed cache and return (rc, call log).
|
||||
|
||||
``reconfigure_rcs`` overrides the exit codes of the first reconfigures;
|
||||
later ones succeed.
|
||||
"""
|
||||
calls: list[tuple] = []
|
||||
rcs = iter(reconfigure_rcs)
|
||||
|
||||
def record_reconfigure() -> int:
|
||||
calls.append(("run_reconfigure",))
|
||||
return next(rcs, 0)
|
||||
|
||||
def record_write(minimal: bool = False, builtin_components=None) -> None:
|
||||
calls.append(("write_project", minimal, builtin_components))
|
||||
|
||||
def record_save(components: list[str]) -> None:
|
||||
calls.append(("save", components))
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=cached),
|
||||
patch.object(
|
||||
toolchain, "save_cached_builtin_components", side_effect=record_save
|
||||
),
|
||||
patch("esphome.build_gen.espidf.get_available_components", return_value=saved),
|
||||
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
|
||||
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
|
||||
patch.object(
|
||||
toolchain, "_builtin_component_cache_path", return_value=cache_file
|
||||
),
|
||||
patch.object(
|
||||
toolchain,
|
||||
"run_idf_py",
|
||||
side_effect=lambda *a, **kw: calls.append(("build",)) or 0,
|
||||
),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
return rc, calls
|
||||
|
||||
|
||||
def test_run_compile_poisoned_cache_is_dropped_and_rediscovered(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A cached list that fails the configure is deleted and discovery runs
|
||||
once more instead of every later build failing the same way."""
|
||||
_setup_build(setup_core)
|
||||
cache_file = tmp_path / "esp32-abc.json"
|
||||
cache_file.write_text("[]")
|
||||
rc, calls = _record_compile_calls(
|
||||
["stale"], saved=["lwip"], reconfigure_rcs=(1,), cache_file=cache_file
|
||||
)
|
||||
assert rc == 0
|
||||
assert not cache_file.exists()
|
||||
assert calls == [
|
||||
("write_project", False, ["stale"]),
|
||||
("run_reconfigure",),
|
||||
("write_project", True, None),
|
||||
("run_reconfigure",),
|
||||
("write_project", False, ["lwip"]),
|
||||
("run_reconfigure",),
|
||||
("save", ["lwip"]),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None:
|
||||
"""Without a cached list the discovery configure runs, the discovered list
|
||||
feeds the full write and is cached only after that configure succeeds."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=["lwip"])
|
||||
assert rc == 0
|
||||
assert calls == [
|
||||
("write_project", True, None),
|
||||
("run_reconfigure",),
|
||||
("write_project", False, ["lwip"]),
|
||||
("run_reconfigure",),
|
||||
("save", ["lwip"]),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
def test_run_compile_discovery_failure_stops_before_full_write(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A failed discovery configure returns its exit code and never writes
|
||||
the full CMakeLists, a cache entry or a build."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, reconfigure_rcs=(2,))
|
||||
assert rc == 2
|
||||
assert calls == [("write_project", True, None), ("run_reconfigure",)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovered", [None, []], ids=["no_manifest", "empty"])
|
||||
def test_run_compile_fails_when_discovery_finds_nothing(
|
||||
setup_core: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
discovered: list[str] | None,
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=discovered)
|
||||
assert rc == 1
|
||||
assert calls == [("write_project", True, None), ("run_reconfigure",)]
|
||||
assert "found no built-in ESP-IDF components" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_does_not_cache_a_list_that_failed_to_configure(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=["lwip"], reconfigure_rcs=(0, 3))
|
||||
assert rc == 3
|
||||
assert ("save", ["lwip"]) not in calls
|
||||
assert ("build",) not in calls
|
||||
|
||||
|
||||
def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None:
|
||||
"""A cached list goes straight to the full write; the explicit reconfigure
|
||||
after it (#18730) still runs."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(["esp_timer", "lwip"])
|
||||
assert rc == 0
|
||||
assert calls == [
|
||||
("write_project", False, ["esp_timer", "lwip"]),
|
||||
("run_reconfigure",),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]:
|
||||
"""Patch everything the cache key derives from onto a temp IDF tree and
|
||||
yield that tree's path."""
|
||||
idf_path = tmp_path / "idf"
|
||||
(idf_path / "components").mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_path", return_value=idf_path),
|
||||
patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}),
|
||||
patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}),
|
||||
):
|
||||
yield idf_path
|
||||
|
||||
|
||||
def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None:
|
||||
"""A saved list is read back until it is dropped."""
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
for name in ("lwip", "esp_timer"):
|
||||
(idf_path / "components" / name).mkdir()
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
toolchain.save_cached_builtin_components(["esp_timer", "lwip"])
|
||||
assert toolchain.load_cached_builtin_components() == ["esp_timer", "lwip"]
|
||||
toolchain._builtin_component_cache_path().unlink()
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
def test_component_cache_misses_on_key_change_or_missing_component(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A different exclusion set uses another entry, an entry naming a
|
||||
component that no longer exists is ignored, and a custom IDF_PATH is
|
||||
never cached."""
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
(idf_path / "components" / "lwip").mkdir()
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
path = toolchain._builtin_component_cache_path()
|
||||
assert path.parent == idf_path / ".esphome_component_lists"
|
||||
assert path.name.startswith("esp32-")
|
||||
assert toolchain.load_cached_builtin_components() == ["lwip"]
|
||||
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
with _cache_env(tmp_path, "fatfs;unity"):
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
path.write_text(json.dumps(["lwip", "gone"]))
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
# A plain file with the right name is not a component directory.
|
||||
(idf_path / "components" / "gone").write_text("not a directory")
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
def test_component_cache_save_skips_empty_list_or_custom_idf_path(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "") as idf_path:
|
||||
toolchain.save_cached_builtin_components([])
|
||||
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
assert not (idf_path / ".esphome_component_lists").exists()
|
||||
|
||||
|
||||
def test_component_cache_write_failure_is_logged(
|
||||
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
with (
|
||||
_cache_env(tmp_path, ""),
|
||||
patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")),
|
||||
):
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
assert "Could not write component list cache" in caplog.text
|
||||
|
||||
|
||||
def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None:
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, ""):
|
||||
path = toolchain._builtin_component_cache_path()
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{not json")
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
path.write_text(json.dumps({"components": ["lwip"]}))
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ from collections.abc import Generator
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
@@ -44,13 +46,18 @@ def mock_file() -> io.BytesIO:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time() -> Generator[None]:
|
||||
def mock_sleep() -> Generator[Mock]:
|
||||
"""Mock time.sleep so delays don't slow down tests."""
|
||||
with patch("time.sleep") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time(mock_sleep: Mock) -> Generator[None]:
|
||||
"""Mock time-related functions for consistent testing."""
|
||||
# Provide enough values for multiple calls (tests may call perform_ota multiple times)
|
||||
with (
|
||||
patch("time.sleep"),
|
||||
patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]),
|
||||
):
|
||||
# Monotonically increasing, never exhausted regardless of how many timing
|
||||
# windows perform_ota measures or how many times a test calls it
|
||||
with patch("time.perf_counter", side_effect=itertools.count()):
|
||||
yield
|
||||
|
||||
|
||||
@@ -79,6 +86,28 @@ def mock_resolve_ip() -> Generator[Mock]:
|
||||
yield mock
|
||||
|
||||
|
||||
DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0)
|
||||
DUAL_STACK_SA4 = ("192.168.1.100", 3232)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock:
|
||||
"""Make resolve_ip_address return an IPv6 and an IPv4 address."""
|
||||
mock_resolve_ip.return_value = [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4),
|
||||
]
|
||||
return mock_resolve_ip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firmware_file(tmp_path: Path) -> Path:
|
||||
"""Create a firmware file on disk for run_ota_impl_ tests."""
|
||||
firmware = tmp_path / "firmware.bin"
|
||||
firmware.write_bytes(b"firmware content")
|
||||
return firmware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_perform_ota() -> Generator[Mock]:
|
||||
"""Mock perform_ota function for testing."""
|
||||
@@ -137,9 +166,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth:.*Authentication invalid"
|
||||
):
|
||||
) as exc_info:
|
||||
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
|
||||
|
||||
# Device-reported errors must stay plain OTAError, not the retryable kind
|
||||
assert not isinstance(exc_info.value, espota2.OTANetworkError)
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -147,10 +178,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors."""
|
||||
mock_socket.recv.side_effect = OSError("Connection reset")
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving test response"):
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test response"):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors after the first byte."""
|
||||
mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test:"):
|
||||
espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly raises OTANetworkError when the device closes the connection."""
|
||||
mock_socket.recv.return_value = b""
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_msg"),
|
||||
[
|
||||
@@ -227,15 +278,15 @@ def test_check_error_unexpected_response() -> None:
|
||||
|
||||
|
||||
def test_check_error_empty_data() -> None:
|
||||
"""Test check_error raises error when device closes connection without responding."""
|
||||
"""Test check_error raises the retryable OTANetworkError when the device closes the connection."""
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error([], [espota2.RESPONSE_OK])
|
||||
|
||||
# Also test with empty bytes
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error(b"", [espota2.RESPONSE_OK])
|
||||
|
||||
@@ -324,7 +375,9 @@ def test_perform_ota_successful_md5_auth(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
def test_perform_ota_no_auth(
|
||||
mock_socket: Mock, mock_file: io.BytesIO, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test OTA without authentication."""
|
||||
recv_responses = [
|
||||
bytes([espota2.RESPONSE_OK]), # First byte of version response
|
||||
@@ -339,7 +392,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
# Distinct window lengths pin each duration to its label; exactly the 6
|
||||
# expected perf_counter calls, so an unaccounted timing window raises
|
||||
timings = [0.0, 2.0, 10.0, 15.0, 20.0, 27.0]
|
||||
with (
|
||||
patch("time.perf_counter", side_effect=timings),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# Should not send any auth-related data
|
||||
auth_calls = [
|
||||
@@ -349,6 +409,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
]
|
||||
assert len(auth_calls) == 0
|
||||
|
||||
# The timing summary is the observable output of the upload; exact strings
|
||||
# pin each duration to its label
|
||||
assert "Preparing for upload took 2.00 seconds" in caplog.text
|
||||
assert (
|
||||
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_with_compression(mock_socket: Mock) -> None:
|
||||
@@ -530,6 +598,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
def _no_auth_handshake(version: int) -> list[bytes]:
|
||||
"""Recv responses for a handshake without auth, up to the MD5 check."""
|
||||
return [
|
||||
bytes([espota2.RESPONSE_OK]), # First byte of version response
|
||||
bytes([version]), # Version number
|
||||
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
|
||||
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
|
||||
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
|
||||
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
"""Test OTA raises the retryable OTANetworkError when sending a chunk fails."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Probe for a pending error byte fails too
|
||||
]
|
||||
# Sends before the data phase: magic bytes, features, binary size, MD5;
|
||||
# fail on the fifth sendall, the first firmware chunk
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="sending data:"):
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error_surfaces_device_error(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a device error byte pending behind a send failure becomes the cause."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed
|
||||
]
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Writing OTA data to flash memory failed"
|
||||
) as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device-reported error is not retryable
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_final_chunk_ack_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a lost ack for the final chunk is not retried."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the only (final) chunk is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device already had the whole image, so it may be committing
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_intermediate_chunk_ack_failure_retryable(
|
||||
mock_socket: Mock,
|
||||
) -> None:
|
||||
"""Test a lost ack for a non-final chunk stays retryable."""
|
||||
# Two chunks: the firmware is larger than one upload block
|
||||
big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1))
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the first of two chunks is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"):
|
||||
espota2.perform_ota(mock_socket, None, big_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_post_commit_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a network failure after the device committed is a plain OTAError."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
OSError("Connection reset"), # Connection lost waiting for end result
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving update end result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# Must not be the retryable kind; the device is already rebooting
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_md5_mismatch_not_marked_committed(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test an MD5 mismatch keeps its own message and stays non-retryable."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device aborted without committing, so the message must not claim
|
||||
# the update may have been installed, and the error must not be retried
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
assert "committed" not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_end_ack_send_failure_is_success(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a send failure on the final acknowledgement does not fail the OTA."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed
|
||||
]
|
||||
# Sends: magic bytes, features, binary size, MD5, one firmware chunk;
|
||||
# fail on the sixth sendall, the end acknowledgement
|
||||
mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")]
|
||||
|
||||
# Must not raise; the device treats a missing acknowledgement as non-fatal
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
assert mock_socket.sendall.call_count == 6
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_successful(
|
||||
mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock
|
||||
@@ -564,21 +770,183 @@ def test_run_ota_impl_successful(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None:
|
||||
"""Test run_ota_impl_ when connection fails."""
|
||||
def test_run_ota_impl_connection_failed(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries when connection fails and eventually gives up."""
|
||||
mock_socket.connect.side_effect = OSError("Connection refused")
|
||||
|
||||
# Create a real firmware file
|
||||
firmware_file = tmp_path / "firmware.bin"
|
||||
firmware_file.write_bytes(b"firmware content")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_socket.close.assert_called_once()
|
||||
# A single address gets the whole attempt budget, with a delay before
|
||||
# each revisit
|
||||
assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connect_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ succeeds when a retry connects after a failed attempt."""
|
||||
mock_socket.connect.side_effect = [OSError("Connection timed out"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_socket.connect.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries after a network error during the upload."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("receiving features: Device closed connection"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_perform_ota.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_exhausts_attempts(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ gives up after all attempts hit network errors."""
|
||||
mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_multiple_addresses_cycle(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ visits every address and cycles for the retries."""
|
||||
mock_socket.connect.side_effect = OSError("No route to host")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
# Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare
|
||||
# attempts cycle back through them; the budget is shared, not per address
|
||||
assert mock_socket.connect.call_args_list == [
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
]
|
||||
# No connect ever reached the device, so the delay only applies before
|
||||
# the revisits
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_second_address_succeeds_without_delay(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ falls through to the next address with no pause."""
|
||||
mock_socket.connect.side_effect = [OSError("No route to host"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
mock_sleep.assert_not_called()
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_pauses_after_reaching_device(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ pauses before the next address once the device was reached."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("sending data: connection reset"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
# The first attempt reached the device, so the next one waits first even
|
||||
# though it targets a fresh address
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_device_error_not_retried(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails immediately on a device-reported error."""
|
||||
mock_perform_ota.side_effect = espota2.OTAError(
|
||||
"Authentication invalid. Is the password correct?"
|
||||
)
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_perform_ota.assert_called_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_run_ota_impl_no_addresses(
|
||||
firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails cleanly when resolution yields no addresses."""
|
||||
mock_resolve_ip.return_value = []
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None:
|
||||
@@ -630,10 +998,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
|
||||
assert "100%" in captured.err
|
||||
assert "Done" in captured.err
|
||||
|
||||
# Test done method
|
||||
# done() after the 100% frame adds nothing; that frame ended its line
|
||||
progress.done()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == "\n"
|
||||
assert captured.err == ""
|
||||
|
||||
# Test same progress doesn't update
|
||||
progress.update(0.5)
|
||||
@@ -642,6 +1010,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
|
||||
# Should only see one update (second call shouldn't write)
|
||||
assert captured.err.count("50%") == 1
|
||||
|
||||
# done() after a mid-way frame ends the line
|
||||
progress.done()
|
||||
assert capsys.readouterr().err == "\n"
|
||||
|
||||
|
||||
# Tests for SHA256 authentication
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_requests_head() -> MagicMock:
|
||||
"""Patch `external_files.requests.head` so the conditional HEAD-request
|
||||
validator can be tested without doing real HTTP.
|
||||
"""Patch `requests.head` so the conditional HEAD-request validator can
|
||||
be tested without doing real HTTP. Patched on the requests module
|
||||
because external_files imports it lazily inside the function.
|
||||
"""
|
||||
with patch("esphome.external_files.requests.head") as m:
|
||||
with patch("requests.head") as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_requests_get() -> MagicMock:
|
||||
"""Patch `external_files.requests.get` so the download path can be
|
||||
tested without doing real HTTP.
|
||||
"""Patch `requests.get` so the download path can be tested without
|
||||
doing real HTTP. Patched on the requests module because
|
||||
external_files imports it lazily inside the function.
|
||||
"""
|
||||
with patch("esphome.external_files.requests.get") as m:
|
||||
with patch("requests.get") as m:
|
||||
yield m
|
||||
|
||||
|
||||
@@ -78,6 +81,15 @@ def mock_download_content_many() -> MagicMock:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_retry_sleep() -> MagicMock:
|
||||
"""Patch the retry backoff sleep (process-wide; net_retry.time is the
|
||||
global module) so transient-error tests don't really wait 2s/4s.
|
||||
"""
|
||||
with patch("esphome.net_retry.time.sleep") as m:
|
||||
yield m
|
||||
|
||||
|
||||
def test_compute_local_file_dir(setup_core: Path) -> None:
|
||||
"""Test compute_local_file_dir creates and returns correct path."""
|
||||
domain = "font"
|
||||
@@ -492,6 +504,7 @@ class _BodyReadErrorResponse:
|
||||
def test_download_content_with_body_read_error_uses_cache(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Body-read errors (chunked-decode/gzip-decode/mid-stream connection
|
||||
@@ -516,6 +529,7 @@ def test_download_content_with_body_read_error_uses_cache(
|
||||
def test_download_content_with_body_read_error_no_cache_fails(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A body-read failure with no cache available must surface as a
|
||||
@@ -532,6 +546,131 @@ def test_download_content_with_body_read_error_no_cache_fails(
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
|
||||
def test_download_content_retries_transient_error_then_succeeds(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Transient failures (connection reset, timeout) are retried with 2s/4s
|
||||
backoff before giving up; a late success downloads normally."""
|
||||
test_file = setup_core / "downloads" / "file.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
ok = MagicMock()
|
||||
ok.content = b"downloaded"
|
||||
ok.headers = {}
|
||||
mock_requests_get.side_effect = [
|
||||
requests.exceptions.ConnectionError("reset by peer"),
|
||||
requests.exceptions.Timeout("timed out"),
|
||||
ok,
|
||||
]
|
||||
|
||||
result = external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert result == b"downloaded"
|
||||
assert test_file.read_bytes() == b"downloaded"
|
||||
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
|
||||
|
||||
|
||||
def test_download_content_transient_error_exhausts_attempts(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A persistent transient failure gives up after three attempts and then
|
||||
follows the normal no-cache error path."""
|
||||
test_file = setup_core / "nonexistent.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.ConnectionError("reset by peer")
|
||||
|
||||
with pytest.raises(Invalid, match="Could not download from.*reset by peer"):
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
|
||||
|
||||
|
||||
def test_download_content_non_transient_error_not_retried(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Permanent failures like a 404 fail on the first attempt."""
|
||||
test_file = setup_core / "nonexistent.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 404
|
||||
mock_requests_get.side_effect = requests.exceptions.HTTPError(
|
||||
"404 Client Error", response=response
|
||||
)
|
||||
|
||||
with pytest.raises(Invalid, match="Could not download from.*404"):
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert mock_requests_get.call_count == 1
|
||||
mock_retry_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_download_content_retries_body_read_error(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Mid-stream failures surfacing from `.content` are retried too."""
|
||||
test_file = setup_core / "downloads" / "file.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
ok = MagicMock()
|
||||
ok.content = b"downloaded"
|
||||
ok.headers = {}
|
||||
mock_requests_get.side_effect = [
|
||||
_BodyReadErrorResponse(
|
||||
requests.exceptions.ChunkedEncodingError("body truncated")
|
||||
),
|
||||
ok,
|
||||
]
|
||||
|
||||
result = external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert result == b"downloaded"
|
||||
assert mock_requests_get.call_count == 2
|
||||
assert mock_retry_sleep.call_args_list == [call(2)]
|
||||
|
||||
|
||||
def test_has_remote_file_changed_retries_transient_error(
|
||||
mock_requests_head: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A HEAD revalidation that fails transiently then returns 304 does not
|
||||
mark the cached copy stale, and the retry warning names the operation."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
ok = MagicMock()
|
||||
ok.status_code = 304
|
||||
ok.headers = {}
|
||||
mock_requests_head.side_effect = [
|
||||
requests.exceptions.ConnectionError("reset by peer"),
|
||||
ok,
|
||||
]
|
||||
|
||||
changed = external_files.has_remote_file_changed(
|
||||
"https://example.com/file.txt", test_file
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
assert test_file not in external_files._run_data().stale_paths
|
||||
assert mock_requests_head.call_count == 2
|
||||
assert mock_retry_sleep.call_args_list == [call(2)]
|
||||
assert "Revalidation of" in caplog.text
|
||||
|
||||
|
||||
def test_download_content_skip_external_update_uses_cache(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
@@ -549,6 +688,10 @@ def test_download_content_skip_external_update_uses_cache(
|
||||
assert result == cached_content
|
||||
mock_has_remote_file_changed.assert_not_called()
|
||||
mock_requests_get.assert_not_called()
|
||||
# Deliberately unchecked is memoized for the run but never "fresh".
|
||||
assert not external_files.is_fresh_this_run(test_file)
|
||||
assert external_files.download_content(url, test_file) == cached_content
|
||||
mock_has_remote_file_changed.assert_not_called()
|
||||
|
||||
|
||||
def test_download_content_skip_external_update_downloads_when_missing(
|
||||
@@ -587,10 +730,16 @@ def test_download_content_many_single_item_avoids_pool(
|
||||
mock_download_content: MagicMock, setup_core: Path
|
||||
) -> None:
|
||||
"""A single item should be downloaded inline (no thread pool overhead)."""
|
||||
item = ("https://example.com/file.txt", setup_core / "f.txt")
|
||||
item = external_files.RemoteFile(
|
||||
"https://example.com/file.txt", setup_core / "f.txt"
|
||||
)
|
||||
external_files.download_content_many([item])
|
||||
mock_download_content.assert_called_once_with(
|
||||
item[0], item[1], external_files.NETWORK_TIMEOUT
|
||||
item.url,
|
||||
item.path,
|
||||
external_files.NETWORK_TIMEOUT,
|
||||
allow_stale=True,
|
||||
return_content=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -602,7 +751,12 @@ def test_download_content_many_runs_in_parallel(
|
||||
|
||||
barrier = threading.Barrier(3)
|
||||
|
||||
def slow_download(url: str, path: Path, timeout: int) -> bytes:
|
||||
def slow_download(
|
||||
url: str,
|
||||
path: Path,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> bytes:
|
||||
# If calls were serial this would deadlock (third caller never arrives
|
||||
# while the first is blocked at the barrier).
|
||||
barrier.wait(timeout=2.0)
|
||||
@@ -610,9 +764,9 @@ def test_download_content_many_runs_in_parallel(
|
||||
|
||||
mock_download_content.side_effect = slow_download
|
||||
items = [
|
||||
("https://example.com/a", setup_core / "a"),
|
||||
("https://example.com/b", setup_core / "b"),
|
||||
("https://example.com/c", setup_core / "c"),
|
||||
external_files.RemoteFile("https://example.com/a", setup_core / "a"),
|
||||
external_files.RemoteFile("https://example.com/b", setup_core / "b"),
|
||||
external_files.RemoteFile("https://example.com/c", setup_core / "c"),
|
||||
]
|
||||
external_files.download_content_many(items, max_workers=4)
|
||||
assert mock_download_content.call_count == 3
|
||||
@@ -625,15 +779,20 @@ def test_download_content_many_propagates_single_error(
|
||||
it in a `MultipleInvalid` that the caller would have to unpack.
|
||||
"""
|
||||
|
||||
def fake_download(url: str, path: Path, timeout: int) -> bytes:
|
||||
def fake_download(
|
||||
url: str,
|
||||
path: Path,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> bytes:
|
||||
if url.endswith("bad"):
|
||||
raise Invalid(f"could not download {url}")
|
||||
return b""
|
||||
|
||||
mock_download_content.side_effect = fake_download
|
||||
items = [
|
||||
("https://example.com/ok", setup_core / "ok"),
|
||||
("https://example.com/bad", setup_core / "bad"),
|
||||
external_files.RemoteFile("https://example.com/ok", setup_core / "ok"),
|
||||
external_files.RemoteFile("https://example.com/bad", setup_core / "bad"),
|
||||
]
|
||||
with pytest.raises(Invalid, match="could not download") as exc_info:
|
||||
external_files.download_content_many(items)
|
||||
@@ -648,16 +807,21 @@ def test_download_content_many_aggregates_multiple_errors(
|
||||
them one network round-trip at a time.
|
||||
"""
|
||||
|
||||
def fake_download(url: str, path: Path, timeout: int) -> bytes:
|
||||
def fake_download(
|
||||
url: str,
|
||||
path: Path,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> bytes:
|
||||
if url.endswith("ok"):
|
||||
return b""
|
||||
raise Invalid(f"could not download {url}")
|
||||
|
||||
mock_download_content.side_effect = fake_download
|
||||
items = [
|
||||
("https://example.com/ok", setup_core / "ok"),
|
||||
("https://example.com/bad1", setup_core / "bad1"),
|
||||
("https://example.com/bad2", setup_core / "bad2"),
|
||||
external_files.RemoteFile("https://example.com/ok", setup_core / "ok"),
|
||||
external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"),
|
||||
external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"),
|
||||
]
|
||||
with pytest.raises(MultipleInvalid) as exc_info:
|
||||
external_files.download_content_many(items)
|
||||
@@ -678,9 +842,9 @@ def test_download_content_many_dedupes_by_path(
|
||||
"""
|
||||
path = setup_core / "shared"
|
||||
items = [
|
||||
("https://example.com/a", path),
|
||||
("https://example.com/b", path),
|
||||
("https://example.com/a", path),
|
||||
external_files.RemoteFile("https://example.com/a", path),
|
||||
external_files.RemoteFile("https://example.com/b", path),
|
||||
external_files.RemoteFile("https://example.com/a", path),
|
||||
]
|
||||
external_files.download_content_many(items)
|
||||
assert mock_download_content.call_count == 1
|
||||
@@ -695,8 +859,8 @@ def test_download_content_many_clamps_invalid_max_workers(
|
||||
be clamped up to at least 1 worker.
|
||||
"""
|
||||
items = [
|
||||
("https://example.com/a", setup_core / "a"),
|
||||
("https://example.com/b", setup_core / "b"),
|
||||
external_files.RemoteFile("https://example.com/a", setup_core / "a"),
|
||||
external_files.RemoteFile("https://example.com/b", setup_core / "b"),
|
||||
]
|
||||
external_files.download_content_many(items, max_workers=0)
|
||||
assert mock_download_content.call_count == 2
|
||||
@@ -724,8 +888,8 @@ def test_download_web_files_in_config_filters_and_dispatches(
|
||||
assert result is config
|
||||
mock_download_content_many.assert_called_once()
|
||||
assert list(mock_download_content_many.call_args[0][0]) == [
|
||||
("https://example.com/a", setup_core / "a"),
|
||||
("https://example.com/c", setup_core / "c"),
|
||||
external_files.RemoteFile("https://example.com/a", setup_core / "a"),
|
||||
external_files.RemoteFile("https://example.com/c", setup_core / "c"),
|
||||
]
|
||||
|
||||
|
||||
@@ -799,3 +963,264 @@ def test_download_content_atomic_write_no_partial_on_failure(
|
||||
# into the cache directory either way.
|
||||
leftover_tmps = list(setup_core.glob("tmp*"))
|
||||
assert leftover_tmps == []
|
||||
|
||||
|
||||
def test_download_content_memoizes_fresh_path(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A path downloaded once this run skips all network on later calls."""
|
||||
test_file = setup_core / "memo.txt"
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"fresh content"
|
||||
mock_response.headers = {}
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
assert external_files.download_content(url, test_file) == b"fresh content"
|
||||
assert external_files.download_content(url, test_file) == b"fresh content"
|
||||
|
||||
mock_has_remote_file_changed.assert_called_once()
|
||||
mock_requests_get.assert_called_once()
|
||||
|
||||
|
||||
def test_download_content_memo_revalidates_deleted_file(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A memoized path whose file vanished is downloaded again."""
|
||||
test_file = setup_core / "memo.txt"
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"fresh content"
|
||||
mock_response.headers = {}
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
external_files.download_content(url, test_file)
|
||||
test_file.unlink()
|
||||
external_files.download_content(url, test_file)
|
||||
|
||||
assert mock_requests_get.call_count == 2
|
||||
|
||||
|
||||
def test_download_content_failure_fails_fast_on_retry(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A failed download is remembered; a retry raises without network."""
|
||||
test_file = setup_core / "memo.txt"
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
with pytest.raises(Invalid, match="boom"):
|
||||
external_files.download_content(url, test_file)
|
||||
with pytest.raises(Invalid, match="boom"):
|
||||
external_files.download_content(url, test_file)
|
||||
|
||||
mock_requests_get.assert_called_once()
|
||||
|
||||
|
||||
def test_download_content_failed_path_revalidates_when_file_appears(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A recorded failure is dropped once the file exists on disk."""
|
||||
test_file = setup_core / "memo.txt"
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
with pytest.raises(Invalid):
|
||||
external_files.download_content(url, test_file)
|
||||
|
||||
# Another writer produced the file; the cached failure no longer applies
|
||||
# and the network error now falls back to the on-disk copy.
|
||||
test_file.write_bytes(b"appeared")
|
||||
assert external_files.download_content(url, test_file) == b"appeared"
|
||||
|
||||
|
||||
def test_download_content_network_error_fallback_memoizes(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Falling back to a cached file memoizes, so a flaky host is hit once."""
|
||||
test_file = setup_core / "memo.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
|
||||
mock_requests_get.assert_called_once()
|
||||
|
||||
|
||||
def test_download_content_not_changed_uses_cache(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A 304 not-changed check serves the cached file without a GET."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
mock_has_remote_file_changed.return_value = False
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
|
||||
mock_requests_get.assert_not_called()
|
||||
|
||||
|
||||
def test_head_failure_fallback_is_stale_not_fresh(
|
||||
mock_requests_head: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A HEAD network failure serves the copy once and memoizes it as stale."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
mock_requests_head.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
|
||||
mock_requests_head.assert_called_once()
|
||||
mock_requests_get.assert_not_called()
|
||||
|
||||
|
||||
def test_allow_stale_false_rejects_unverified_copy(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""allow_stale=False raises instead of building from an unverified copy."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
with pytest.raises(Invalid, match="Could not download"):
|
||||
external_files.download_content(url, test_file, allow_stale=False)
|
||||
|
||||
# A strict caller gets its own attempt at the network rather than
|
||||
# inheriting the stale memo's verdict.
|
||||
with pytest.raises(Invalid, match="Could not download"):
|
||||
external_files.download_content(url, test_file, allow_stale=False)
|
||||
assert mock_requests_get.call_count == 2
|
||||
|
||||
# A caller that tolerates stale copies still gets the cached bytes.
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
|
||||
|
||||
def test_allow_stale_false_rejects_head_failure_fallback(
|
||||
mock_requests_head: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""allow_stale=False also rejects a copy the HEAD could not confirm."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
mock_requests_head.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
with pytest.raises(Invalid, match="cannot be verified"):
|
||||
external_files.download_content(url, test_file, allow_stale=False)
|
||||
mock_requests_get.assert_not_called()
|
||||
|
||||
|
||||
def test_download_content_many_forwards_per_file_allow_stale(
|
||||
mock_download_content: MagicMock, setup_core: Path
|
||||
) -> None:
|
||||
"""Each RemoteFile's own allow_stale reaches download_content."""
|
||||
files = [
|
||||
external_files.RemoteFile("https://example.com/a", setup_core / "a"),
|
||||
external_files.RemoteFile(
|
||||
"https://example.com/b", setup_core / "b", allow_stale=False
|
||||
),
|
||||
]
|
||||
external_files.download_content_many(files)
|
||||
forwarded = {
|
||||
call.args[1]: call.kwargs["allow_stale"]
|
||||
for call in mock_download_content.call_args_list
|
||||
}
|
||||
assert forwarded == {setup_core / "a": True, setup_core / "b": False}
|
||||
|
||||
|
||||
def test_download_content_many_dedupe_keeps_strictest(
|
||||
mock_download_content: MagicMock, setup_core: Path
|
||||
) -> None:
|
||||
"""A strict duplicate wins over a permissive one for the same path."""
|
||||
path = setup_core / "fw.bin"
|
||||
files = [
|
||||
external_files.RemoteFile("https://example.com/fw", path, allow_stale=False),
|
||||
external_files.RemoteFile("https://example.com/fw", path),
|
||||
]
|
||||
external_files.download_content_many(files)
|
||||
mock_download_content.assert_called_once()
|
||||
assert mock_download_content.call_args.kwargs["allow_stale"] is False
|
||||
|
||||
|
||||
def test_successful_head_revalidation_clears_stale(
|
||||
mock_requests_head: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A confirmed 304 supersedes an earlier failed revalidation."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
ok_304 = MagicMock(status_code=304, headers={})
|
||||
mock_requests_head.side_effect = [
|
||||
requests.exceptions.RequestException("blip"),
|
||||
ok_304,
|
||||
]
|
||||
|
||||
url = "https://example.com/file.txt"
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
# The stale memo short-circuits tolerant callers; a strict caller
|
||||
# triggers a fresh HEAD, which now succeeds and clears the marker.
|
||||
assert (
|
||||
external_files.download_content(url, test_file, allow_stale=False)
|
||||
== b"cached content"
|
||||
)
|
||||
# Verified now: served from the fresh memo with no more network.
|
||||
assert external_files.download_content(url, test_file) == b"cached content"
|
||||
assert mock_requests_head.call_count == 2
|
||||
mock_requests_get.assert_not_called()
|
||||
|
||||
|
||||
def test_failed_path_replay_names_the_other_url(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A shared cache path replays the failure naming the original URL."""
|
||||
test_file = setup_core / "shared.bin"
|
||||
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
|
||||
|
||||
with pytest.raises(Invalid, match="first-url"):
|
||||
external_files.download_content("https://example.com/first-url", test_file)
|
||||
with pytest.raises(Invalid, match="earlier download of.*first-url"):
|
||||
external_files.download_content("https://example.com/second-url", test_file)
|
||||
mock_requests_get.assert_called_once()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2001
-70
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
"""Tests for the Happy Eyeballs urllib3 shim."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs
|
||||
|
||||
|
||||
def _addr_info(host: str, port: int) -> tuple[Any, ...]:
|
||||
"""Build a getaddrinfo-style result tuple for an IPv4 address."""
|
||||
return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_connection() -> Any:
|
||||
"""A freshly built Happy Eyeballs create_connection replacement."""
|
||||
return _make_create_connection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listener() -> Generator[tuple[str, int]]:
|
||||
"""A listening TCP socket on localhost; yields its address."""
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(5)
|
||||
yield server.getsockname()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gai(listener: tuple[str, int]) -> Generator[Any]:
|
||||
"""Resolve every host to two copies of the listener's address."""
|
||||
with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def test_ensure_happy_eyeballs_patches_and_is_idempotent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The shim replaces urllib3's create_connection exactly once."""
|
||||
import urllib3.util.connection
|
||||
|
||||
def stock(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
|
||||
|
||||
ensure_happy_eyeballs()
|
||||
patched = urllib3.util.connection.create_connection
|
||||
assert patched is not stock
|
||||
assert patched._esphome_patched
|
||||
|
||||
ensure_happy_eyeballs()
|
||||
assert urllib3.util.connection.create_connection is patched
|
||||
|
||||
|
||||
def test_ensure_happy_eyeballs_concurrent_first_calls_patch_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Worker threads fanning out (download_content_many, run_batch_downloads)
|
||||
may race the first call; the replacement is built exactly once."""
|
||||
import urllib3.util.connection
|
||||
|
||||
from esphome import happy_eyeballs
|
||||
|
||||
def stock(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
builds: list[int] = []
|
||||
real_make = happy_eyeballs._make_create_connection
|
||||
|
||||
def counting_make() -> Any:
|
||||
builds.append(1)
|
||||
return real_make()
|
||||
|
||||
monkeypatch.setattr(happy_eyeballs, "_make_create_connection", counting_make)
|
||||
|
||||
def racer() -> None:
|
||||
barrier.wait(timeout=10)
|
||||
ensure_happy_eyeballs()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||
list(ex.map(lambda _: racer(), range(8)))
|
||||
|
||||
assert builds == [1]
|
||||
assert urllib3.util.connection.create_connection._esphome_patched
|
||||
|
||||
|
||||
def test_connects_and_restores_socket_state(
|
||||
create_connection: Any, listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
"""The winning socket comes back blocking, with timeout and options set."""
|
||||
sock = create_connection(
|
||||
("example.com", listener[1]),
|
||||
timeout=5,
|
||||
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
|
||||
)
|
||||
|
||||
try:
|
||||
assert sock.getpeername() == listener
|
||||
assert sock.gettimeout() == 5
|
||||
assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_single_address_connects(
|
||||
create_connection: Any, listener: tuple[str, int]
|
||||
) -> None:
|
||||
"""A host resolving to one address connects through the same path."""
|
||||
with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]):
|
||||
sock = create_connection(("example.com", listener[1]), timeout=5)
|
||||
|
||||
try:
|
||||
assert sock.getpeername() == listener
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_falls_back_to_working_address(
|
||||
create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An unreachable first address does not block the working one."""
|
||||
from esphome import happy_eyeballs
|
||||
|
||||
# 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the
|
||||
# network; either way the second address must win well within the
|
||||
# timeout instead of waiting out the first. A short stagger keeps the
|
||||
# test's duration network independent.
|
||||
monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01)
|
||||
addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)]
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=addr_infos):
|
||||
sock = create_connection(("example.com", listener[1]), timeout=10)
|
||||
|
||||
try:
|
||||
assert sock.getpeername() == listener
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_bracketed_ipv6_host_is_stripped(
|
||||
create_connection: Any, listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
"""A bracketed IPv6 literal is unbracketed before resolution."""
|
||||
sock = create_connection(("[::1]", listener[1]), timeout=5)
|
||||
|
||||
try:
|
||||
assert mock_gai.call_args[0][0] == "::1"
|
||||
assert sock.getpeername() == listener
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_source_address_is_bound(
|
||||
create_connection: Any, listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
"""The socket binds to the requested source address before connecting."""
|
||||
sock = create_connection(
|
||||
("example.com", listener[1]),
|
||||
timeout=5,
|
||||
source_address=("127.0.0.1", 0),
|
||||
)
|
||||
|
||||
try:
|
||||
assert sock.getsockname()[0] == "127.0.0.1"
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_socket_factory_failure_closes_socket(
|
||||
listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
"""A socket-option failure fails the connect instead of leaking sockets.
|
||||
|
||||
Instrumented at ``_set_socket_options`` (which the factory calls with
|
||||
the just-created socket) rather than by patching ``socket.socket``,
|
||||
which is platform dependent: the event loop's internal socketpair use
|
||||
differs between platforms.
|
||||
"""
|
||||
created: list[socket.socket] = []
|
||||
|
||||
def failing_set_options(sock: socket.socket, options: Any) -> None:
|
||||
created.append(sock)
|
||||
raise OSError("bad socket option")
|
||||
|
||||
# Patch before building the closure; it binds _set_socket_options at
|
||||
# creation time.
|
||||
with patch("urllib3.util.connection._set_socket_options", new=failing_set_options):
|
||||
create_connection = _make_create_connection()
|
||||
with pytest.raises(OSError):
|
||||
create_connection(
|
||||
("example.com", listener[1]),
|
||||
timeout=5,
|
||||
socket_options=[(999999, 999999, 1)],
|
||||
)
|
||||
|
||||
assert created, "socket factory never ran"
|
||||
assert all(sock.fileno() == -1 for sock in created), "socket leaked open"
|
||||
|
||||
|
||||
def test_default_timeout_yields_blocking_socket(
|
||||
create_connection: Any, listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
"""Without an explicit timeout the socket follows the global default."""
|
||||
sock = create_connection(("example.com", listener[1]))
|
||||
|
||||
try:
|
||||
assert sock.gettimeout() is socket.getdefaulttimeout()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_settimeout_failure_closes_socket(
|
||||
create_connection: Any, mock_gai: Any
|
||||
) -> None:
|
||||
"""A failure restoring socket state closes the winner instead of leaking."""
|
||||
bad_sock = Mock()
|
||||
bad_sock.settimeout.side_effect = OSError("bad timeout")
|
||||
|
||||
with (
|
||||
patch("esphome.async_thread.run_async", return_value=bad_sock),
|
||||
pytest.raises(OSError, match="bad timeout"),
|
||||
):
|
||||
create_connection(("example.com", 80), timeout=5)
|
||||
|
||||
bad_sock.close.assert_called_once()
|
||||
|
||||
|
||||
def test_connect_timeout_raises() -> None:
|
||||
"""A connect that never completes raises within the timeout."""
|
||||
|
||||
async def never(*args: Any, **kwargs: Any) -> None:
|
||||
await asyncio.sleep(60)
|
||||
|
||||
addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)]
|
||||
|
||||
# Patch before building the closure; it binds start_connection at
|
||||
# creation time.
|
||||
with patch("aiohappyeyeballs.start_connection", new=never):
|
||||
create_connection = _make_create_connection()
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=addr_infos),
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
create_connection(("example.com", 80), timeout=0.1)
|
||||
|
||||
|
||||
def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None:
|
||||
"""Hostnames urllib3 would reject are still rejected."""
|
||||
from urllib3.exceptions import LocationParseError
|
||||
|
||||
with pytest.raises(LocationParseError):
|
||||
create_connection(("a" * 300, 80))
|
||||
|
||||
|
||||
def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None:
|
||||
"""An empty resolution matches stock urllib3's OSError, not ValueError."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[]),
|
||||
pytest.raises(OSError, match="empty"),
|
||||
):
|
||||
create_connection(("example.com", 80), timeout=5)
|
||||
|
||||
|
||||
def test_ensure_falls_back_to_stock_when_internals_move(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""If urllib3 private names disappear, downloads keep the stock connect
|
||||
and the warning is latched to fire once, not per download."""
|
||||
import urllib3.util.connection
|
||||
|
||||
from esphome import happy_eyeballs
|
||||
|
||||
def stock(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
factory = Mock(side_effect=ImportError("gone"))
|
||||
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
|
||||
monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory)
|
||||
|
||||
ensure_happy_eyeballs()
|
||||
ensure_happy_eyeballs()
|
||||
assert urllib3.util.connection.create_connection is stock
|
||||
assert factory.call_count == 1
|
||||
assert caplog.text.count("Happy Eyeballs unavailable") == 1
|
||||
|
||||
|
||||
def test_ensure_survives_missing_urllib3(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unimportable urllib3 degrades with a warning instead of raising."""
|
||||
import sys
|
||||
|
||||
with patch.dict(sys.modules, {"urllib3.util.connection": None}):
|
||||
ensure_happy_eyeballs()
|
||||
assert "Happy Eyeballs unavailable" in caplog.text
|
||||
|
||||
|
||||
def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patching urllib3's create_connection actually reroutes requests."""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import urllib3.util.connection
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
host, port = server.server_address
|
||||
|
||||
calls: list[Any] = []
|
||||
shim = _make_create_connection()
|
||||
|
||||
def counting(*args: Any, **kwargs: Any) -> Any:
|
||||
calls.append(args)
|
||||
return shim(*args, **kwargs)
|
||||
|
||||
counting._esphome_patched = True
|
||||
monkeypatch.setattr(urllib3.util.connection, "create_connection", counting)
|
||||
|
||||
real_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any:
|
||||
if h == "shim-test.invalid":
|
||||
return [_addr_info(host, port), _addr_info(host, port)]
|
||||
return real_getaddrinfo(h, p, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
session.trust_env = False
|
||||
resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"ok"
|
||||
assert calls, "requests did not go through the patched create_connection"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@@ -1,10 +1,12 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import stat
|
||||
from unittest.mock import MagicMock, patch
|
||||
import types
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
|
||||
from hypothesis import given, settings
|
||||
@@ -14,7 +16,7 @@ import pytest
|
||||
from esphome import helpers
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import ProgressBar
|
||||
from esphome.helpers import ProgressBar, format_ip_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -135,6 +137,22 @@ def test_is_ip_address__invalid(host):
|
||||
assert actual is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("family", "sockaddr", "expected"),
|
||||
(
|
||||
(socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"),
|
||||
(socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"),
|
||||
(
|
||||
socket.AF_INET6,
|
||||
("fe80::1", 8080, 0, 7),
|
||||
"http://[fe80::1%257]:8080/events",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_format_ip_url(family, sockaddr, expected):
|
||||
assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected
|
||||
|
||||
|
||||
@settings(deadline=None)
|
||||
@given(value=ip_addresses(v=4).map(str))
|
||||
def test_is_ip_address__valid(value):
|
||||
@@ -155,6 +173,13 @@ def test_is_ip_address__valid(value):
|
||||
("FOO", "fAlSe", True, False),
|
||||
("FOO", "Yes", False, True),
|
||||
("FOO", "123", False, True),
|
||||
# cv.boolean's spellings; falsy rows use default=True on purpose
|
||||
("FOO", "on", False, True),
|
||||
("FOO", "enable", False, True),
|
||||
("FOO", "no", True, False),
|
||||
("FOO", "off", True, False),
|
||||
("FOO", "OFF", True, False),
|
||||
("FOO", "Disable", True, False),
|
||||
),
|
||||
)
|
||||
def test_get_bool_env(monkeypatch, var, value, default, expected):
|
||||
@@ -237,6 +262,31 @@ class Test_write_file_if_changed:
|
||||
|
||||
assert dst.read_text() == text
|
||||
|
||||
def test_damaged_existing_file_is_replaced(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""A non-UTF-8 existing file is logged and overwritten."""
|
||||
dst = tmp_path / "generated.txt"
|
||||
dst.write_bytes(b"\xff\xfe")
|
||||
|
||||
assert helpers.write_file_if_changed(dst, "fresh content") is True
|
||||
|
||||
assert dst.read_text(encoding="utf-8") == "fresh content"
|
||||
assert "Replacing damaged file" in caplog.text
|
||||
|
||||
def test_unreadable_existing_file_still_raises(self, tmp_path: Path):
|
||||
"""An OSError on the comparison read still raises EsphomeError."""
|
||||
dst = tmp_path / "generated.txt"
|
||||
dst.write_text("intact")
|
||||
|
||||
with (
|
||||
patch.object(Path, "read_text", side_effect=OSError("permission denied")),
|
||||
pytest.raises(EsphomeError, match="Error reading file"),
|
||||
):
|
||||
helpers.write_file_if_changed(dst, "fresh content")
|
||||
|
||||
assert dst.exists()
|
||||
|
||||
def test_dst_does_not_exist(self, tmp_path: Path):
|
||||
text = "A files are unique.\n"
|
||||
dst = tmp_path / "file-a.txt"
|
||||
@@ -917,6 +967,77 @@ def test_copy_file_if_changed_nonexistent_source(tmp_path: Path) -> None:
|
||||
helpers.copy_file_if_changed(src, dst)
|
||||
|
||||
|
||||
def test_rmtree_removes_tree(tmp_path: Path) -> None:
|
||||
"""Test rmtree removes a populated directory tree."""
|
||||
target = tmp_path / "target"
|
||||
(target / "sub").mkdir(parents=True)
|
||||
(target / "sub" / "file.txt").write_text("content")
|
||||
|
||||
helpers.rmtree(target)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_rmtree_nonexistent_path(tmp_path: Path) -> None:
|
||||
"""Test rmtree on an already-removed path is a no-op."""
|
||||
helpers.rmtree(tmp_path / "gone")
|
||||
|
||||
|
||||
def test_rmtree_retries_when_directory_repopulated(tmp_path: Path) -> None:
|
||||
"""Test rmtree retries when a file appears mid-delete (Finder .DS_Store race)."""
|
||||
target = tmp_path / "target"
|
||||
(target / "sub").mkdir(parents=True)
|
||||
real_rmdir = os.rmdir
|
||||
repopulated = False
|
||||
|
||||
def racy_rmdir(path, **kwargs):
|
||||
nonlocal repopulated
|
||||
if not repopulated and Path(path).name == "target":
|
||||
repopulated = True
|
||||
(target / ".DS_Store").write_text("x") # Finder wins the race
|
||||
real_rmdir(path, **kwargs)
|
||||
|
||||
with patch("os.rmdir", side_effect=racy_rmdir), patch("time.sleep"):
|
||||
helpers.rmtree(target)
|
||||
assert repopulated
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_rmtree_raises_after_retries_exhausted(tmp_path: Path) -> None:
|
||||
"""Test rmtree gives up on a persistent ENOTEMPTY once attempts run out."""
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
errs = [
|
||||
OSError(errno.ENOTEMPTY, "Directory not empty", str(target))
|
||||
for _ in range(helpers.RMTREE_MAX_ATTEMPTS)
|
||||
]
|
||||
|
||||
with (
|
||||
patch("shutil.rmtree", side_effect=errs) as mock_rmtree,
|
||||
patch("time.sleep") as mock_sleep,
|
||||
pytest.raises(OSError, match="Directory not empty") as excinfo,
|
||||
):
|
||||
helpers.rmtree(target)
|
||||
assert mock_rmtree.call_count == helpers.RMTREE_MAX_ATTEMPTS
|
||||
assert mock_sleep.call_args_list == [call(0.05), call(0.1)]
|
||||
# Final failure chains to the last retried race
|
||||
assert excinfo.value is errs[-1]
|
||||
assert excinfo.value.__cause__ is errs[-2]
|
||||
|
||||
|
||||
def test_rmtree_does_not_retry_other_oserror(tmp_path: Path) -> None:
|
||||
"""Test rmtree raises non-ENOTEMPTY errors immediately."""
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
err = OSError(errno.EACCES, "Permission denied", str(target))
|
||||
|
||||
with (
|
||||
patch("shutil.rmtree", side_effect=err) as mock_rmtree,
|
||||
pytest.raises(OSError, match="Permission denied"),
|
||||
):
|
||||
helpers.rmtree(target)
|
||||
assert mock_rmtree.call_count == 1
|
||||
|
||||
|
||||
def test_resolve_ip_address_sorting() -> None:
|
||||
"""Test that results are sorted by preference."""
|
||||
# Create multiple address infos with different preferences
|
||||
@@ -1074,3 +1195,58 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
assert bar.enabled is True
|
||||
|
||||
|
||||
def test_progressbar_interrupt_keeps_finished_bar_done(monkeypatch) -> None:
|
||||
"""interrupt() on a bar whose 100% frame already ended its own line
|
||||
must not reset it, or the next tick would redraw a second Done row."""
|
||||
stream = MagicMock(spec=io.TextIOWrapper)
|
||||
stream.isatty.return_value = True
|
||||
monkeypatch.setattr(CORE, "dashboard", False)
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
bar.update(1)
|
||||
assert bar.last_progress == 100
|
||||
bar.interrupt()
|
||||
assert bar.last_progress == 100
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def test_get_usable_cpu_count() -> None:
|
||||
"""Returns a positive int on the real host."""
|
||||
count = helpers.get_usable_cpu_count()
|
||||
assert isinstance(count, int)
|
||||
assert count > 0
|
||||
|
||||
|
||||
def test_get_usable_cpu_count_sources() -> None:
|
||||
"""Prefers process_cpu_count, falls back to cpu_count, degrades to 1."""
|
||||
mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4)
|
||||
with patch("esphome.helpers.os", mock_os):
|
||||
assert helpers.get_usable_cpu_count() == 8
|
||||
|
||||
mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4)
|
||||
with patch("esphome.helpers.os", mock_os_no_process):
|
||||
assert helpers.get_usable_cpu_count() == 4
|
||||
|
||||
# An undeterminable count degrades to one worker, never zero
|
||||
mock_os_unknown = types.SimpleNamespace(cpu_count=lambda: None)
|
||||
with patch("esphome.helpers.os", mock_os_unknown):
|
||||
assert helpers.get_usable_cpu_count() == 1
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""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 importlib.util
|
||||
from pathlib import Path
|
||||
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",
|
||||
)
|
||||
|
||||
# Everything the storage fast path must keep out of sys.modules; the
|
||||
# existence guard and the leak check must watch the same list.
|
||||
FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",)
|
||||
|
||||
# Heavy only for modules that must not know about the API transport;
|
||||
# in the existence guard so a rename can't silently no-op its check.
|
||||
API_HEAVY_MODULES = ("aioesphomeapi",)
|
||||
|
||||
# Heavy only for the single-config dispatch path: the bundle suffix
|
||||
# check reads BUNDLE_EXTENSION from esphome.const so an ordinary run
|
||||
# never pays for the bundle machinery and its tarfile chain.
|
||||
BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile")
|
||||
|
||||
# Heavy only for a cache-hit upload/logs run: the JSON cache parse must
|
||||
# not resolve pyyaml or the yaml_util chain (the read_config fallback
|
||||
# still uses both).
|
||||
CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml")
|
||||
|
||||
# Stdlib modules deferred out of the dispatch fast path: a cache-hit
|
||||
# upload/logs run never writes a file (tempfile), spawns a process
|
||||
# (subprocess), parses a URL (urllib.parse), or prints a serial
|
||||
# permission hint (getpass). shutil is deferred too but unwatchable:
|
||||
# argparse imports it from every add_argument on py3.14. urllib.parse
|
||||
# is only watchable on 3.13+ where pathlib stopped importing it.
|
||||
STDLIB_FAST_PATH_MODULES = (
|
||||
"tempfile",
|
||||
"subprocess",
|
||||
"getpass",
|
||||
"datetime",
|
||||
*(("urllib.parse",) if sys.version_info >= (3, 13) else ()),
|
||||
)
|
||||
|
||||
|
||||
def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str:
|
||||
"""Import ``module`` in a subprocess and report the heavy modules it pulled.
|
||||
|
||||
Any ``esphome.components.*`` package counts as heavy: executing a
|
||||
component package drags in codegen/validation machinery by design.
|
||||
``extra`` adds modules that are heavy for this caller specifically.
|
||||
"""
|
||||
check = (
|
||||
f"import sys; import {module}; "
|
||||
f"leaked = [m for m in {HEAVY_MODULES + extra!r} if m in sys.modules]; "
|
||||
"leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; "
|
||||
"print(','.join(leaked))"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", check],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_main_module_does_not_import_heavy_modules() -> None:
|
||||
"""A bare ``import esphome.__main__`` must not drag in validation/codegen.
|
||||
|
||||
The stdlib watch list rides along here because this check runs in a
|
||||
clean subprocess: a module-level re-import anywhere on the chain is
|
||||
caught, which the dispatch fixture (whose setup pre-imports them and
|
||||
pops before dispatch) structurally cannot do.
|
||||
"""
|
||||
leaked = _leaked_heavy_modules("esphome.__main__", extra=STDLIB_FAST_PATH_MODULES)
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def test_watched_heavy_modules_exist() -> None:
|
||||
"""A renamed heavy module would silently disable the leak checks."""
|
||||
for module in (
|
||||
FAST_PATH_HEAVY_MODULES
|
||||
+ API_HEAVY_MODULES
|
||||
+ BUNDLE_HEAVY_MODULES
|
||||
+ CACHE_HIT_HEAVY_MODULES
|
||||
+ STDLIB_FAST_PATH_MODULES
|
||||
):
|
||||
assert importlib.util.find_spec(module) is not None, (
|
||||
f"{module} no longer resolves; update the heavy-module lists"
|
||||
)
|
||||
|
||||
|
||||
def _leaked_from_fixture(
|
||||
fixture_path: Path,
|
||||
env: dict[str, str],
|
||||
script_name: str,
|
||||
extra: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""Run a fixture script with the watched modules on argv.
|
||||
|
||||
``env`` comes from the ``probe_env`` fixture so the child can import
|
||||
the repo checkout; a non-zero exit surfaces the child's stderr.
|
||||
"""
|
||||
script = fixture_path / "lazy_imports" / script_name
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_storage_json_fast_path_does_not_import_heavy_modules(
|
||||
fixture_path: Path,
|
||||
probe_env: dict[str, str],
|
||||
) -> None:
|
||||
"""``apply_to_core`` runs on the upload/logs fast path for every
|
||||
platform; parsing the stored framework version must not drag in the
|
||||
validation stack or the esp32 component package.
|
||||
"""
|
||||
leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py")
|
||||
assert not leaked, (
|
||||
f"storage_json.apply_to_core pulls in heavy modules: {leaked}. "
|
||||
"The upload/logs fast path skips validation; importing the "
|
||||
"validation stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_esptool_upload_fast_path_does_not_import_heavy_modules(
|
||||
fixture_path: Path,
|
||||
probe_env: dict[str, str],
|
||||
) -> None:
|
||||
"""The esptool serial upload reads the esp32 variant from CORE.data;
|
||||
resolving it must not drag in the esp32 component package or the
|
||||
validation stack.
|
||||
"""
|
||||
leaked = _leaked_from_fixture(
|
||||
fixture_path, probe_env, "esptool_upload_fast_path.py"
|
||||
)
|
||||
assert not leaked, (
|
||||
f"upload_using_esptool pulls in heavy modules: {leaked}. "
|
||||
"The upload fast path skips validation; importing the validation "
|
||||
"stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_api_client_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.api_client`` is on the logs fast path and must stay light.
|
||||
|
||||
Importing it must not execute any component package (the api package
|
||||
pulls the whole validation stack: logger, esp32, writer, config,
|
||||
jinja2, voluptuous).
|
||||
"""
|
||||
leaked = _leaked_heavy_modules("esphome.api_client")
|
||||
assert not leaked, (
|
||||
f"esphome.api_client imports heavy modules at top level: {leaked}. "
|
||||
"The logs fast path skips validation; importing the validation "
|
||||
"stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_stacktrace_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.stacktrace`` guards its own docstring's contract.
|
||||
|
||||
Both log paths construct a LogLineProcessor before streaming
|
||||
starts; importing the module must not pull in aioesphomeapi or
|
||||
any platform package.
|
||||
"""
|
||||
leaked = _leaked_heavy_modules("esphome.stacktrace", extra=API_HEAVY_MODULES)
|
||||
assert not leaked, (
|
||||
f"esphome.stacktrace imports heavy modules at top level: {leaked}. "
|
||||
"The logs fast path skips validation; importing the validation "
|
||||
"stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_espidf_toolchain_does_not_import_heavy_modules() -> None:
|
||||
"""The esp-idf upload path must not pull the esp32 package back in.
|
||||
|
||||
upload_using_esptool reaches espidf.toolchain for esp-idf builds;
|
||||
its keys and the variant mapping live in esphome.const and
|
||||
esphome.espidf precisely so this import stays light.
|
||||
"""
|
||||
leaked = _leaked_heavy_modules("esphome.espidf.toolchain")
|
||||
assert not leaked, (
|
||||
f"esphome.espidf.toolchain imports heavy modules: {leaked}. "
|
||||
"The upload fast path skips validation; importing the validation "
|
||||
"stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None:
|
||||
"""``has_mqtt_ip_lookup`` runs on the upload/logs fast path for mqtt
|
||||
configs; reading ``CONF_DISCOVER_IP`` must not drag in the mqtt
|
||||
component and, with it, the validation stack.
|
||||
|
||||
Runs in a subprocess because this session's other tests import the
|
||||
mqtt component; the fast path itself must not.
|
||||
"""
|
||||
check = (
|
||||
"import sys; from esphome.__main__ import has_mqtt_ip_lookup; "
|
||||
"from esphome.core import CORE; from esphome.const import CONF_MQTT; "
|
||||
"CORE.config = {CONF_MQTT: {}}; "
|
||||
"assert has_mqtt_ip_lookup() is True, 'mqtt IP lookup default broke'; "
|
||||
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
|
||||
"leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; "
|
||||
"print(','.join(leaked))"
|
||||
)
|
||||
# check=False keeps the child's stderr (its assertion message or an
|
||||
# import traceback) visible on failure.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", check],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
leaked = result.stdout.strip()
|
||||
assert not leaked, (
|
||||
f"has_mqtt_ip_lookup pulls in heavy modules: {leaked}. "
|
||||
"The upload/logs fast path skips validation; importing the "
|
||||
"validation stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_yaml_util_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.yaml_util`` parses the validated-config cache on the
|
||||
upload/logs fast path; importing it must not pull in voluptuous.
|
||||
"""
|
||||
leaked = _leaked_heavy_modules("esphome.yaml_util")
|
||||
assert not leaked, (
|
||||
f"esphome.yaml_util imports heavy modules at top level: {leaked}. "
|
||||
"The upload/logs fast path skips validation; importing the "
|
||||
"validation stack anyway defeats the validated-config cache."
|
||||
)
|
||||
|
||||
|
||||
def test_upload_command_path_does_not_import_heavy_modules(
|
||||
fixture_path: Path,
|
||||
probe_env: dict[str, str],
|
||||
) -> None:
|
||||
"""The single-config dispatch path checks the bundle suffix on every
|
||||
run; reading it from esphome.const must not drag in esphome.bundle
|
||||
and its tarfile chain.
|
||||
"""
|
||||
leaked = _leaked_from_fixture(
|
||||
fixture_path,
|
||||
probe_env,
|
||||
"upload_command_fast_path.py",
|
||||
extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES,
|
||||
)
|
||||
assert not leaked, (
|
||||
f"the upload dispatch path pulls in heavy modules: {leaked}. "
|
||||
"An ordinary run only needs the bundle suffix constant, and the "
|
||||
"JSON cache parse must not resolve voluptuous or pyyaml; keep the "
|
||||
"esphome.bundle import inside the branch that extracts one, the "
|
||||
"yaml_util imports inside the read_config fallback, and the "
|
||||
"deferred stdlib imports inside the write/spawn/serial helpers."
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.component_aliases import COMPONENT_ALIASES
|
||||
from esphome.loader import (
|
||||
AliasMeta,
|
||||
ComponentManifest,
|
||||
@@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None:
|
||||
assert meta["rp2040"].removal_version == "2027.7.0"
|
||||
|
||||
|
||||
def test_alias_registry_matches_component_tree() -> None:
|
||||
"""The checked-in registry must match a live scan of the component tree."""
|
||||
_, meta_map = _build_alias_map()
|
||||
expected = {
|
||||
alias: (meta.canonical, meta.removal_version)
|
||||
for alias, meta in meta_map.items()
|
||||
}
|
||||
assert expected == COMPONENT_ALIASES, (
|
||||
"esphome/component_aliases.py is out of date; "
|
||||
"run script/build_alias_registry.py"
|
||||
)
|
||||
|
||||
|
||||
def test_alias_map_built_from_registry() -> None:
|
||||
"""The runtime alias map comes from the generated registry, not a scan."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.component_aliases.COMPONENT_ALIASES",
|
||||
{"legacy": ("modern", "2099.1.0")},
|
||||
),
|
||||
patch("esphome.loader._ALIAS_META_CACHE", None),
|
||||
):
|
||||
assert get_alias_metadata() == {
|
||||
"legacy": AliasMeta(canonical="modern", removal_version="2099.1.0")
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
from collections.abc import Generator
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.log import AnsiFore, AnsiStyle, color
|
||||
from esphome.core import CORE
|
||||
from esphome.log import AnsiFore, AnsiStyle, color, setup_log
|
||||
|
||||
|
||||
class _FakeTty(io.StringIO):
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_logging_state() -> Generator[None, None, None]:
|
||||
"""Undo the global logging changes setup_log() makes."""
|
||||
root = logging.getLogger()
|
||||
handlers = root.handlers[:]
|
||||
formatters = [handler.formatter for handler in handlers]
|
||||
level = root.level
|
||||
urllib3_level = logging.getLogger("urllib3").level
|
||||
yield
|
||||
root.handlers[:] = handlers
|
||||
for handler, formatter in zip(handlers, formatters, strict=True):
|
||||
handler.setFormatter(formatter)
|
||||
root.setLevel(level)
|
||||
logging.getLogger("urllib3").setLevel(urllib3_level)
|
||||
|
||||
|
||||
def _probe_command(fixture_path: Path, *args: str) -> list[str]:
|
||||
"""Build the command line for the setup_log probe fixture script."""
|
||||
return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args]
|
||||
|
||||
|
||||
def test_color_keep_returns_unchanged_message() -> None:
|
||||
@@ -78,3 +115,227 @@ def test_ansi_fore_keep_is_enum_member() -> None:
|
||||
assert bool(AnsiFore.KEEP) is True
|
||||
# But the value itself is still an empty string
|
||||
assert AnsiFore.KEEP.value == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="colorama always initializes on Windows"
|
||||
)
|
||||
def test_setup_log_redirected_output_strips_ansi(
|
||||
fixture_path: Path, probe_env: dict[str, str]
|
||||
) -> None:
|
||||
"""A redirected run must keep colorama so ANSI codes are stripped."""
|
||||
result = subprocess.run(
|
||||
_probe_command(fixture_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
env=probe_env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "colorama_loaded=True" in result.stdout
|
||||
assert "red end" in result.stdout
|
||||
assert "\033" not in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="colorama always initializes on Windows"
|
||||
)
|
||||
def test_setup_log_dashboard_skips_colorama(
|
||||
fixture_path: Path, probe_env: dict[str, str]
|
||||
) -> None:
|
||||
"""Dashboard runs escape their color codes, so colorama must not load."""
|
||||
result = subprocess.run(
|
||||
_probe_command(fixture_path, "--dashboard"),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
env=probe_env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "colorama_loaded=False" in result.stdout
|
||||
# Codes pass through untouched for the dashboard to handle.
|
||||
assert "\033[31mred\033[0m end" in result.stdout
|
||||
|
||||
|
||||
def _run_probe_on_pty(
|
||||
fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool
|
||||
) -> str:
|
||||
"""Run the probe with stdout on a pty and return the decoded pty output.
|
||||
|
||||
With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the
|
||||
mixed tty/redirect stream combination while keeping any traceback
|
||||
available for the exit assertion.
|
||||
"""
|
||||
# Unix-only; a module-level import would break test collection on
|
||||
# Windows, where all the callers are skipped anyway.
|
||||
import pty
|
||||
|
||||
controller, follower = pty.openpty()
|
||||
proc = None
|
||||
output = b""
|
||||
deadline = time.monotonic() + 60
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
_probe_command(fixture_path),
|
||||
stdout=follower,
|
||||
stderr=follower if stderr_to_pty else subprocess.PIPE,
|
||||
stdin=follower,
|
||||
env=probe_env,
|
||||
)
|
||||
# The parent keeps the follower open until the child has exited and
|
||||
# the controller is drained: macOS discards buffered pty output once
|
||||
# the last follower closes, so closing it early loses the probe's
|
||||
# output whenever the child finishes before the first read.
|
||||
while proc.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail(f"pty probe did not exit in time; got {output!r}")
|
||||
if select.select([controller], [], [], 0.01)[0]:
|
||||
output += os.read(controller, 4096)
|
||||
# Everything the child wrote is already buffered, so drain without waiting.
|
||||
while select.select([controller], [], [], 0)[0] and (
|
||||
chunk := os.read(controller, 4096)
|
||||
):
|
||||
output += chunk
|
||||
stderr_text = ""
|
||||
if proc.stderr is not None:
|
||||
stderr_text = proc.stderr.read().decode(errors="replace")
|
||||
proc.stderr.close()
|
||||
assert proc.returncode == 0, stderr_text
|
||||
finally:
|
||||
os.close(follower)
|
||||
os.close(controller)
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
return output.decode()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
|
||||
)
|
||||
def test_setup_log_tty_skips_colorama(
|
||||
fixture_path: Path, probe_env: dict[str, str]
|
||||
) -> None:
|
||||
"""A terminal run must skip colorama and keep ANSI codes intact."""
|
||||
text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True)
|
||||
assert "colorama_loaded=False" in text
|
||||
assert "\033[31mred\033[0m end" in text
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
|
||||
)
|
||||
def test_setup_log_mixed_streams_init_colorama(
|
||||
fixture_path: Path, probe_env: dict[str, str]
|
||||
) -> None:
|
||||
"""A tty stdout with a redirected stderr must still initialize colorama.
|
||||
|
||||
The guard requires both streams to be a tty; collapsing it to a
|
||||
single-stream check would stop stripping ANSI from a redirected
|
||||
stderr while stdout is a terminal.
|
||||
"""
|
||||
text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False)
|
||||
assert "colorama_loaded=True" in text
|
||||
# stdout is a tty, so colorama leaves its codes alone.
|
||||
assert "\033[31mred\033[0m end" in text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def colorama_probe(
|
||||
monkeypatch: pytest.MonkeyPatch, restore_logging_state: None
|
||||
) -> Generator[None, None, None]:
|
||||
"""Shared preamble for the in-process guard-branch tests.
|
||||
|
||||
Clears colorama from sys.modules so the assertions prove what
|
||||
setup_log() itself did, and snapshots CORE.verbose/quiet, which is
|
||||
not a no-op: CORE.reset() does not restore them, so without the
|
||||
snapshot setup_log()'s log-level side effects would leak into later
|
||||
tests.
|
||||
"""
|
||||
monkeypatch.delitem(sys.modules, "colorama", raising=False)
|
||||
monkeypatch.setattr(CORE, "verbose", CORE.verbose)
|
||||
monkeypatch.setattr(CORE, "quiet", CORE.quiet)
|
||||
yield
|
||||
# init() rebinds sys.stdout/stderr; restore them before monkeypatch
|
||||
# puts the originals back.
|
||||
if (colorama := sys.modules.get("colorama")) is not None:
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="colorama always initializes on Windows"
|
||||
)
|
||||
def test_setup_log_dashboard_branch_skips_colorama_import(
|
||||
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
|
||||
) -> None:
|
||||
"""The dashboard side of the guard must not import colorama."""
|
||||
monkeypatch.setattr(CORE, "dashboard", True)
|
||||
setup_log()
|
||||
assert "colorama" not in sys.modules
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="colorama always initializes on Windows"
|
||||
)
|
||||
def test_setup_log_tty_branch_skips_colorama_import(
|
||||
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
|
||||
) -> None:
|
||||
"""The tty side of the guard must not import colorama."""
|
||||
monkeypatch.setattr(sys, "stdout", _FakeTty())
|
||||
monkeypatch.setattr(sys, "stderr", _FakeTty())
|
||||
setup_log()
|
||||
assert "colorama" not in sys.modules
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="colorama always initializes on Windows"
|
||||
)
|
||||
def test_setup_log_redirected_branch_imports_colorama(
|
||||
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
|
||||
) -> None:
|
||||
"""Redirected streams must keep importing and initializing colorama."""
|
||||
monkeypatch.setattr(sys, "stdout", io.StringIO())
|
||||
monkeypatch.setattr(sys, "stderr", io.StringIO())
|
||||
setup_log()
|
||||
assert "colorama" in sys.modules
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken", ["missing", "closed"])
|
||||
def test_setup_log_broken_streams_import_colorama(
|
||||
broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None
|
||||
) -> None:
|
||||
"""A missing or closed stream counts as a redirect and must not crash.
|
||||
|
||||
colorama tolerates both, so setup_log() has to reach its init rather
|
||||
than raise inside the tty probe.
|
||||
"""
|
||||
if broken == "missing":
|
||||
stream = None
|
||||
else:
|
||||
stream = io.StringIO()
|
||||
stream.close()
|
||||
monkeypatch.setattr(sys, "stdout", stream)
|
||||
monkeypatch.setattr(sys, "stderr", stream)
|
||||
setup_log()
|
||||
assert "colorama" in sys.modules
|
||||
|
||||
|
||||
def test_setup_log_win32_always_imports_colorama(
|
||||
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
|
||||
) -> None:
|
||||
"""The Windows clause must init colorama even when both streams are ttys.
|
||||
|
||||
Old Windows consoles need colorama to translate ANSI escapes, so the
|
||||
platform check has to win over the tty check. colorama itself keys
|
||||
off os.name, so on a POSIX host its init/deinit pair is a
|
||||
passthrough.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
# Both streams are ttys: without the platform clause this combination
|
||||
# would skip colorama.
|
||||
monkeypatch.setattr(sys, "stdout", _FakeTty())
|
||||
monkeypatch.setattr(sys, "stderr", _FakeTty())
|
||||
setup_log()
|
||||
assert "colorama" in sys.modules
|
||||
|
||||
+885
-93
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME
|
||||
@@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None:
|
||||
match="Cannot discover IP via MQTT as the config does not include the device name:",
|
||||
):
|
||||
get_esphome_device_ip(config)
|
||||
|
||||
|
||||
def _discovery_config() -> dict:
|
||||
return {
|
||||
CONF_MQTT: {
|
||||
CONF_BROKER: "mqtt.local",
|
||||
},
|
||||
CONF_ESPHOME: {
|
||||
CONF_NAME: "test-device",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None:
|
||||
"""Deliver a discovery answer as soon as the network loop starts."""
|
||||
|
||||
def deliver(*args, **kwargs):
|
||||
msg = MagicMock()
|
||||
msg.payload = payload
|
||||
mock_prepare.call_args.args[2](client, None, msg)
|
||||
|
||||
client.loop_start.side_effect = deliver
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_success() -> None:
|
||||
"""A device answer on the discovery topic returns its IPs."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
_deliver_on_loop_start(
|
||||
mock_prepare,
|
||||
client,
|
||||
json.dumps(
|
||||
{"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"}
|
||||
).encode(),
|
||||
)
|
||||
|
||||
result = get_esphome_device_ip(_discovery_config())
|
||||
|
||||
assert result == ["10.0.0.5", "10.0.0.6"]
|
||||
client.loop_stop.assert_called_once_with()
|
||||
# Once from on_message on receiving the answer, once from the finally
|
||||
assert client.disconnect.call_count == 2
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None:
|
||||
"""A stop event set before the call returns [] without touching the broker."""
|
||||
stop_event = threading.Event()
|
||||
stop_event.set()
|
||||
|
||||
with patch("esphome.mqtt.prepare") as mock_prepare:
|
||||
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
|
||||
|
||||
assert result == []
|
||||
mock_prepare.assert_not_called()
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_stop_event_aborts_wait() -> None:
|
||||
"""A stop event set mid-wait exits quietly with no addresses."""
|
||||
stop_event = threading.Event()
|
||||
client = MagicMock()
|
||||
# Simulate teardown starting right after the network loop spins up
|
||||
client.loop_start.side_effect = stop_event.set
|
||||
|
||||
start = time.monotonic()
|
||||
with patch("esphome.mqtt.prepare", return_value=client):
|
||||
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
|
||||
|
||||
# An abort is not a failure and must be nowhere near the 25s timeout
|
||||
assert result == []
|
||||
assert time.monotonic() - start < 5
|
||||
client.disconnect.assert_called_once_with()
|
||||
client.loop_stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_timeout_raises() -> None:
|
||||
"""No answer within the timeout raises EsphomeError (default stop event path)."""
|
||||
client = MagicMock()
|
||||
with (
|
||||
patch("esphome.mqtt.prepare", return_value=client),
|
||||
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
|
||||
):
|
||||
get_esphome_device_ip(_discovery_config(), timeout=0.25)
|
||||
|
||||
client.disconnect.assert_called_once_with()
|
||||
client.loop_stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None:
|
||||
"""A stop event set while the broker connect is in flight still cleans up."""
|
||||
stop_event = threading.Event()
|
||||
client = MagicMock()
|
||||
|
||||
def prepare_and_stop(*args):
|
||||
stop_event.set()
|
||||
return client
|
||||
|
||||
with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop):
|
||||
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
|
||||
|
||||
assert result == []
|
||||
client.loop_start.assert_not_called()
|
||||
client.disconnect.assert_called_once_with()
|
||||
client.loop_stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_replaces_reconnect_handler(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The one-shot discovery client must not inherit the reconnect-forever
|
||||
handler, which would make loop_stop() join the network thread forever;
|
||||
its replacement still reports a broker-initiated disconnect."""
|
||||
client = MagicMock()
|
||||
prepare_handler = MagicMock()
|
||||
client.on_disconnect = prepare_handler
|
||||
|
||||
with (
|
||||
patch("esphome.mqtt.prepare", return_value=client),
|
||||
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
|
||||
):
|
||||
get_esphome_device_ip(_discovery_config(), timeout=0.25)
|
||||
|
||||
assert client.on_disconnect is not prepare_handler
|
||||
client.on_disconnect(client, None, 0)
|
||||
assert "Disconnected from MQTT broker" not in caplog.text
|
||||
client.on_disconnect(client, None, 5)
|
||||
assert "Disconnected from MQTT broker (5)" in caplog.text
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_answer_without_ip_fails_fast(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A device answer with no IP fields fails promptly, not at the timeout."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
_deliver_on_loop_start(
|
||||
mock_prepare, client, json.dumps({"name": "test-device"}).encode()
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
|
||||
get_esphome_device_ip(_discovery_config(), timeout=5)
|
||||
|
||||
assert time.monotonic() - start < 1
|
||||
assert "Device answer did not include an IP address" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"])
|
||||
def test_get_esphome_device_ip_unparsable_payload_ignored(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
"""Garbage on the discovery topic must not kill paho's network thread."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
_deliver_on_loop_start(mock_prepare, client, payload)
|
||||
|
||||
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
|
||||
get_esphome_device_ip(_discovery_config(), timeout=0)
|
||||
|
||||
assert "Ignoring unparsable discovery payload" in caplog.text
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_broker_disconnect_fails_fast(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A broker-initiated disconnect aborts the wait instead of timing out."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client):
|
||||
|
||||
def drop_connection(*args, **kwargs):
|
||||
client.on_disconnect(client, None, 5)
|
||||
|
||||
client.loop_start.side_effect = drop_connection
|
||||
|
||||
start = time.monotonic()
|
||||
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
|
||||
get_esphome_device_ip(_discovery_config(), timeout=5)
|
||||
|
||||
assert time.monotonic() - start < 1
|
||||
assert "Disconnected from MQTT broker (5)" in caplog.text
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_sends_discovery_ping() -> None:
|
||||
"""Connecting publishes the discovery ping for the device."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
|
||||
def connect_then_answer(*args, **kwargs):
|
||||
on_connect = mock_prepare.call_args.args[3]
|
||||
on_connect(client, None, None, 0)
|
||||
msg = MagicMock()
|
||||
msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode()
|
||||
mock_prepare.call_args.args[2](client, None, msg)
|
||||
|
||||
client.loop_start.side_effect = connect_then_answer
|
||||
|
||||
result = get_esphome_device_ip(_discovery_config())
|
||||
|
||||
assert result == ["10.0.0.5"]
|
||||
client.publish.assert_called_once_with(
|
||||
"esphome/ping/test-device", None, retain=False
|
||||
)
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_disconnect_error_does_not_mask_result(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A cleanup failure must not replace the discovery result."""
|
||||
client = MagicMock()
|
||||
# First disconnect (from on_message) succeeds; the finally's fails
|
||||
client.disconnect.side_effect = [None, OSError("socket already closed")]
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
_deliver_on_loop_start(
|
||||
mock_prepare,
|
||||
client,
|
||||
json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(),
|
||||
)
|
||||
|
||||
result = get_esphome_device_ip(_discovery_config())
|
||||
|
||||
assert result == ["10.0.0.5"]
|
||||
client.loop_stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_get_esphome_device_ip_invalid_address_values_skipped(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Non-string or non-printable ip values are skipped, valid ones kept."""
|
||||
client = MagicMock()
|
||||
|
||||
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
|
||||
_deliver_on_loop_start(
|
||||
mock_prepare,
|
||||
client,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "test-device",
|
||||
"ip": 1234,
|
||||
"ip1": "x\n[00:00:00][I][forged] fake line",
|
||||
"ip2": " 10.0.0.5 ",
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
|
||||
result = get_esphome_device_ip(_discovery_config())
|
||||
|
||||
assert result == ["10.0.0.5"]
|
||||
assert caplog.text.count("Ignoring invalid address in discovery answer") == 2
|
||||
assert "forged" not in "".join(
|
||||
r.getMessage() for r in caplog.records if "Found IP" in r.getMessage()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for esphome.net_retry."""
|
||||
|
||||
import socket
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
import requests as req
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.net_retry import (
|
||||
fetch_with_retry,
|
||||
http_request,
|
||||
is_transient_download_error,
|
||||
)
|
||||
|
||||
|
||||
def _http_error(status: int) -> req.HTTPError:
|
||||
"""An HTTPError carrying a response with the given status, as raised by
|
||||
``raise_for_status`` on a real response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
return req.HTTPError(str(status), response=resp)
|
||||
|
||||
|
||||
class TestIsTransientDownloadError:
|
||||
def test_connection_errors_are_transient(self) -> None:
|
||||
assert is_transient_download_error(req.ConnectionError("reset"))
|
||||
assert is_transient_download_error(req.Timeout("timed out"))
|
||||
assert is_transient_download_error(
|
||||
req.exceptions.ChunkedEncodingError("dropped")
|
||||
)
|
||||
assert is_transient_download_error(
|
||||
req.exceptions.ContentDecodingError("gzip stream truncated")
|
||||
)
|
||||
|
||||
def test_http_statuses(self) -> None:
|
||||
assert not is_transient_download_error(_http_error(404))
|
||||
assert not is_transient_download_error(_http_error(403))
|
||||
assert is_transient_download_error(_http_error(429))
|
||||
assert is_transient_download_error(_http_error(503))
|
||||
|
||||
def test_http_error_without_response_is_permanent(self) -> None:
|
||||
assert not is_transient_download_error(req.HTTPError("boom"))
|
||||
|
||||
def test_hard_dns_failures_are_permanent(self) -> None:
|
||||
"""Hard resolution failures are permanent via both the cause chain
|
||||
and MaxRetryError.reason."""
|
||||
from urllib3.exceptions import MaxRetryError, NameResolutionError
|
||||
|
||||
gai = socket.gaierror(socket.EAI_NONAME, "nodename nor servname provided")
|
||||
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = gai
|
||||
assert not is_transient_download_error(chained)
|
||||
|
||||
# The real urllib3 shape: gaierror on NameResolutionError.__cause__,
|
||||
# carried by MaxRetryError.reason.
|
||||
try:
|
||||
raise NameResolutionError("example.invalid", None, gai) from gai
|
||||
except NameResolutionError as nre:
|
||||
wrapped = req.ConnectionError(
|
||||
MaxRetryError(None, "http://example.invalid/", reason=nre)
|
||||
)
|
||||
assert not is_transient_download_error(wrapped)
|
||||
|
||||
# A garden-variety connection reset stays transient.
|
||||
assert is_transient_download_error(req.ConnectionError("reset by peer"))
|
||||
|
||||
def test_temporary_dns_failure_stays_transient(self) -> None:
|
||||
"""EAI_AGAIN (flaky resolver) stays retryable."""
|
||||
gai = socket.gaierror(socket.EAI_AGAIN, "temporary failure in name resolution")
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = gai
|
||||
|
||||
assert is_transient_download_error(chained)
|
||||
|
||||
def test_implicit_context_does_not_reclassify(self) -> None:
|
||||
"""A gaierror riding along as implicit __context__ must not turn a
|
||||
genuine connection reset permanent."""
|
||||
try:
|
||||
try:
|
||||
raise socket.gaierror(socket.EAI_NONAME, "first attempt")
|
||||
except socket.gaierror:
|
||||
raise req.ConnectionError("reset by peer") from None
|
||||
except req.ConnectionError as reset:
|
||||
assert reset.__context__ is not None
|
||||
assert is_transient_download_error(reset)
|
||||
|
||||
def test_gaierror_without_errno_stays_transient(self) -> None:
|
||||
"""A gaierror carrying no EAI code cannot prove a hard failure."""
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = socket.gaierror("no errno")
|
||||
|
||||
assert is_transient_download_error(chained)
|
||||
|
||||
def test_mixed_chain_hard_failure_wins(self) -> None:
|
||||
"""EAI_AGAIN in the chain does not mask a hard failure elsewhere."""
|
||||
again = socket.gaierror(socket.EAI_AGAIN, "temporary failure")
|
||||
hard = socket.gaierror(socket.EAI_NONAME, "unknown host")
|
||||
|
||||
outer = req.ConnectionError(hard)
|
||||
outer.__cause__ = again
|
||||
assert not is_transient_download_error(outer)
|
||||
|
||||
outer = req.ConnectionError(again)
|
||||
outer.__cause__ = hard
|
||||
assert not is_transient_download_error(outer)
|
||||
|
||||
def test_dns_walk_survives_exception_cycles(self) -> None:
|
||||
"""A cyclic cause chain must terminate (and stay transient when no
|
||||
resolution failure is present)."""
|
||||
outer = req.ConnectionError("a")
|
||||
inner = ValueError("b")
|
||||
outer.__cause__ = inner
|
||||
inner.__cause__ = outer
|
||||
|
||||
assert is_transient_download_error(outer)
|
||||
|
||||
def test_exhausted_resume_attempts_are_permanent(self) -> None:
|
||||
"""download_with_resume already spent its own resume attempts; its
|
||||
EsphomeError wrapper is not retried again at the sweep level."""
|
||||
wrapped = EsphomeError("Failed to download after 3 attempts")
|
||||
wrapped.__cause__ = req.ConnectionError("down")
|
||||
assert not is_transient_download_error(wrapped)
|
||||
|
||||
def test_unrelated_errors_are_permanent(self) -> None:
|
||||
assert not is_transient_download_error(OSError("disk full"))
|
||||
assert not is_transient_download_error(EsphomeError("size mismatch"))
|
||||
|
||||
|
||||
class TestFetchWithRetry:
|
||||
def test_logs_the_upcoming_attempt_number(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The warning names the attempt about to run, not the failed one."""
|
||||
with (
|
||||
patch("esphome.net_retry.time.sleep") as mock_sleep,
|
||||
pytest.raises(req.ConnectionError),
|
||||
):
|
||||
fetch_with_retry(
|
||||
"https://example.com/f",
|
||||
lambda: (_ for _ in ()).throw(req.ConnectionError("reset")),
|
||||
)
|
||||
|
||||
assert mock_sleep.call_args_list == [call(2), call(4)]
|
||||
assert "(attempt 2/3)" in caplog.text
|
||||
assert "(attempt 3/3)" in caplog.text
|
||||
|
||||
|
||||
class TestHttpRequest:
|
||||
def test_applies_happy_eyeballs_and_forwards_arguments(self) -> None:
|
||||
with (
|
||||
patch("esphome.net_retry.ensure_happy_eyeballs") as mock_he,
|
||||
patch("requests.get", return_value=MagicMock()) as mock_get,
|
||||
):
|
||||
resp = http_request(
|
||||
"GET",
|
||||
"https://example.com/f",
|
||||
timeout=30,
|
||||
stream=True,
|
||||
headers={"Range": "bytes=4-"},
|
||||
)
|
||||
mock_he.assert_called_once_with()
|
||||
assert resp is mock_get.return_value
|
||||
assert mock_get.call_args == call(
|
||||
"https://example.com/f",
|
||||
timeout=30,
|
||||
stream=True,
|
||||
headers={"Range": "bytes=4-"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def test_dispatches_head_through_requests_head(self) -> None:
|
||||
"""Dispatch goes through requests.get/head so tests patching those
|
||||
entry points keep working."""
|
||||
with patch("requests.head", return_value=MagicMock()) as mock_head:
|
||||
http_request("HEAD", "https://example.com/f", timeout=(5, 30))
|
||||
assert mock_head.call_args[1]["timeout"] == (5, 30)
|
||||
|
||||
def test_no_status_handling(self) -> None:
|
||||
"""Error statuses are the caller's problem; nothing raises here."""
|
||||
resp = MagicMock(status_code=404)
|
||||
with patch("requests.get", return_value=resp):
|
||||
assert http_request("GET", "https://example.com/f", timeout=1) is resp
|
||||
@@ -1,22 +1,34 @@
|
||||
"""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 platformdirs
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52 import _resolve_toolchain
|
||||
from esphome.components.nrf52.framework import (
|
||||
_PLATFORMIO_PENV_REQUIREMENTS,
|
||||
_REQUIREMENTS,
|
||||
TOOLCHAIN_VERSION,
|
||||
_get_penv_site_packages,
|
||||
_get_platformio_penv_path,
|
||||
_get_toolchain_platform_info,
|
||||
_needs_venv_rebuild,
|
||||
check_and_install,
|
||||
get_build_env,
|
||||
get_sdk_nrf_tools_path,
|
||||
setup_platformio_python_env,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import Version
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_python_env_executable_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -95,11 +107,13 @@ def mock_nrf52_ops():
|
||||
patch(
|
||||
"esphome.components.nrf52.framework.run_command_ok", return_value=True
|
||||
) as mock_run_cmd,
|
||||
# download_and_extract resolves its internals in framework_helpers,
|
||||
# so the download/extract seams are patched there.
|
||||
patch(
|
||||
"esphome.components.nrf52.framework.download_from_mirrors",
|
||||
"esphome.framework_helpers.download_from_mirrors",
|
||||
return_value="https://example.com/tc.tar.xz",
|
||||
) as mock_download,
|
||||
patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract,
|
||||
patch("esphome.framework_helpers.archive_extract_all") as mock_extract,
|
||||
):
|
||||
yield SimpleNamespace(
|
||||
rmdir=mock_rmdir,
|
||||
@@ -115,10 +129,19 @@ def mock_nrf52_ops():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _touch_penv_python(penv: Path) -> None:
|
||||
"""Create the interpreter file so the rebuild gate sees a live venv."""
|
||||
python = get_python_env_executable_path(penv, "python")
|
||||
python.parent.mkdir(parents=True, exist_ok=True)
|
||||
python.touch()
|
||||
|
||||
|
||||
def _mark_venv_ready(python_env: Path) -> None:
|
||||
"""Write the venv sentinel with the current requirements hash."""
|
||||
"""Write the venv sentinel with the current requirements hash and a
|
||||
present interpreter so the rebuild gate passes."""
|
||||
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
||||
(python_env / ".ready").write_text(requirements_hash, encoding="utf-8")
|
||||
_touch_penv_python(python_env)
|
||||
|
||||
|
||||
class TestCheckAndInstall:
|
||||
@@ -140,6 +163,23 @@ class TestCheckAndInstall:
|
||||
mock_nrf52_ops.download_from_mirrors.assert_not_called()
|
||||
mock_nrf52_ops.archive_extract_all.assert_not_called()
|
||||
|
||||
def test_missing_interpreter_rebuilds_venv(
|
||||
self,
|
||||
nrf52_dirs: SimpleNamespace,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid sentinel must not mask a missing interpreter (a cached venv
|
||||
restored after a host interpreter upgrade)."""
|
||||
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
||||
(nrf52_dirs.python_env / ".ready").write_text(
|
||||
requirements_hash, encoding="utf-8"
|
||||
)
|
||||
# no interpreter on disk
|
||||
|
||||
check_and_install()
|
||||
|
||||
mock_nrf52_ops.create_venv.assert_called_once()
|
||||
|
||||
def test_fresh_install_runs_all_steps(
|
||||
self,
|
||||
nrf52_dirs: SimpleNamespace,
|
||||
@@ -193,6 +233,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,
|
||||
@@ -252,6 +310,237 @@ class TestCheckAndInstall:
|
||||
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"
|
||||
)
|
||||
_touch_penv_python(platformio_penv_dir)
|
||||
|
||||
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_missing_interpreter_reinstalls(
|
||||
self,
|
||||
platformio_penv_dir: Path,
|
||||
mock_nrf52_ops: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid sentinel must not mask a missing interpreter."""
|
||||
(platformio_penv_dir / ".ready").write_text(
|
||||
_platformio_requirements_hash(), encoding="utf-8"
|
||||
)
|
||||
# no interpreter on disk
|
||||
|
||||
with patch.dict(os.environ):
|
||||
setup_platformio_python_env()
|
||||
|
||||
mock_nrf52_ops.create_venv.assert_called_once()
|
||||
|
||||
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"
|
||||
)
|
||||
_touch_penv_python(platformio_penv_dir)
|
||||
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"
|
||||
)
|
||||
_touch_penv_python(platformio_penv_dir)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -274,7 +563,6 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
Path("") would resolve to the working directory, which clean-all could
|
||||
then delete by accident.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
|
||||
expected = (
|
||||
@@ -286,10 +574,59 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
def testget_tools_path_default_is_global_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
|
||||
expected = (
|
||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
||||
).resolve()
|
||||
assert get_sdk_nrf_tools_path() == expected
|
||||
|
||||
|
||||
def test_needs_venv_rebuild_gates(tmp_path: Path) -> None:
|
||||
"""The shared penv gate rebuilds on any missing or stale piece."""
|
||||
penv = tmp_path / "penv"
|
||||
penv.mkdir()
|
||||
python = penv / "python"
|
||||
sentinel = penv / ".ready"
|
||||
good_hash = "abc123"
|
||||
|
||||
# Nothing in place yet
|
||||
assert _needs_venv_rebuild(python, sentinel, good_hash)
|
||||
|
||||
python.write_text("")
|
||||
# Interpreter present but no sentinel
|
||||
assert _needs_venv_rebuild(python, sentinel, good_hash)
|
||||
|
||||
sentinel.write_text(good_hash, encoding="utf-8")
|
||||
# Everything in place
|
||||
assert not _needs_venv_rebuild(python, sentinel, good_hash)
|
||||
|
||||
# Stale requirements hash
|
||||
assert _needs_venv_rebuild(python, sentinel, "otherhash")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="symlink creation needs privileges on Windows"
|
||||
)
|
||||
def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None:
|
||||
"""A cached venv restored after a host interpreter upgrade has a
|
||||
bin/python symlink whose target is gone; the valid sentinel must not
|
||||
mask it."""
|
||||
penv = tmp_path / "penv"
|
||||
penv.mkdir()
|
||||
python = penv / "python"
|
||||
sentinel = penv / ".ready"
|
||||
sentinel.write_text("abc123", encoding="utf-8")
|
||||
python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3")
|
||||
assert python.is_symlink()
|
||||
assert not python.exists()
|
||||
|
||||
assert _needs_venv_rebuild(python, sentinel, "abc123")
|
||||
|
||||
|
||||
def test_resolve_toolchain_rejects_unsupported() -> None:
|
||||
"""A --toolchain nRF52 cannot serve fails instead of degrading silently."""
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
_resolve_toolchain({})
|
||||
|
||||
@@ -13,9 +13,9 @@ from esphome.components.zephyr.const import (
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_KCONFIG,
|
||||
KEY_OVERLAY,
|
||||
KEY_OVERLAY_BUILDER,
|
||||
KEY_PM_STATIC,
|
||||
KEY_PRJ_CONF,
|
||||
KEY_USER,
|
||||
KEY_ZEPHYR,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
@@ -53,9 +53,9 @@ def _setup_nrf52_core(
|
||||
KEY_BOOTLOADER: bootloader,
|
||||
KEY_PRJ_CONF: {},
|
||||
KEY_OVERLAY: {"": ""},
|
||||
KEY_OVERLAY_BUILDER: [],
|
||||
KEY_EXTRA_BUILD_FILES: {},
|
||||
KEY_PM_STATIC: [],
|
||||
KEY_USER: {},
|
||||
KEY_KCONFIG: "",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Guard the platform CLI-hook registry in ``esphome.platform_hooks``.
|
||||
|
||||
The registry lets the logs/upload fast path skip importing platform
|
||||
packages that don't provide a hook; these tests fail when a platform
|
||||
gains or loses a hook without the registry being updated, and pin down
|
||||
that the fast path really avoids the import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import platform_hooks
|
||||
from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform
|
||||
|
||||
|
||||
def test_no_unregistered_platform_exposes_a_hook() -> None:
|
||||
"""Every platform hook the packages expose must be registered.
|
||||
|
||||
Behavioural on purpose: a hook added as a re-export, an assignment,
|
||||
or an ``async def`` is invisible to source scanning but very visible
|
||||
to ``hasattr``, and an unregistered hook is silently never called.
|
||||
The registered direction is covered by
|
||||
test_every_registered_pair_resolves below.
|
||||
"""
|
||||
for platform in frozenset(Platform):
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
for hook, registered in platform_hooks.PLATFORM_HOOKS.items():
|
||||
if hasattr(module, hook):
|
||||
assert platform in registered, (
|
||||
f"{platform} exposes {hook} but is not registered for it. "
|
||||
"Update esphome/platform_hooks.py."
|
||||
)
|
||||
|
||||
|
||||
def test_registered_platform_resolves_hook() -> None:
|
||||
hook = platform_hooks.get_platform_hook(PLATFORM_ESP32, "process_stacktrace")
|
||||
from esphome.components import esp32
|
||||
|
||||
assert hook is esp32.process_stacktrace
|
||||
|
||||
|
||||
def test_every_registered_pair_resolves() -> None:
|
||||
"""Each registered platform must actually expose the hook at runtime.
|
||||
|
||||
Text scanning can miss re-exports or decorated definitions; this is
|
||||
the behavioural check for the direction that matters when the CLI
|
||||
runs.
|
||||
"""
|
||||
for hook, platforms in platform_hooks.PLATFORM_HOOKS.items():
|
||||
for platform in platforms:
|
||||
assert callable(platform_hooks.get_platform_hook(platform, hook)), (
|
||||
f"{platform} is registered for {hook} but does not expose it"
|
||||
)
|
||||
|
||||
|
||||
def test_external_platform_falls_back_to_probe(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Out-of-tree target platforms keep working via the dynamic probe."""
|
||||
module = type("FakePlatform", (), {"show_logs": staticmethod(lambda *a: True)})
|
||||
imported: list[str] = []
|
||||
|
||||
def fake_import(name: str):
|
||||
imported.append(name)
|
||||
return module
|
||||
|
||||
monkeypatch.setattr(platform_hooks, "import_module", fake_import)
|
||||
hook = platform_hooks.get_platform_hook("my_external_chip", "show_logs")
|
||||
assert hook is module.show_logs
|
||||
assert imported == ["esphome.components.my_external_chip"]
|
||||
|
||||
|
||||
def test_external_platform_missing_module_degrades(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A warm-cache run may not have the external package importable.
|
||||
|
||||
Skipping a behavior-changing hook is visible at warning; losing
|
||||
stacktrace decoding is cosmetic and stays at debug.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
platform_hooks,
|
||||
"import_module",
|
||||
Mock(
|
||||
side_effect=ModuleNotFoundError(
|
||||
"not found", name="esphome.components.my_external_chip"
|
||||
)
|
||||
),
|
||||
)
|
||||
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
|
||||
assert "not importable" in caplog.text
|
||||
assert any(r.levelname == "WARNING" for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
assert (
|
||||
platform_hooks.get_platform_hook("my_external_chip", "process_stacktrace")
|
||||
is None
|
||||
)
|
||||
assert not any(r.levelname == "WARNING" for r in caplog.records)
|
||||
|
||||
|
||||
def test_external_platform_without_hook_logs_debug(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The common no-hook case stays quiet but diagnosable."""
|
||||
caplog.set_level("DEBUG", logger="esphome.platform_hooks")
|
||||
module = type("ExternalPlatform", (), {}) # imports fine, no hook
|
||||
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
|
||||
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
|
||||
assert "does not expose" in caplog.text
|
||||
assert not any(r.levelname == "WARNING" for r in caplog.records)
|
||||
|
||||
|
||||
def test_stale_registry_entry_warns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A vendored tree where a registered hook vanished must say so."""
|
||||
module = type("StalePlatform", (), {}) # registered but no hook
|
||||
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
|
||||
assert platform_hooks.get_platform_hook("nrf52", "show_logs") is None
|
||||
assert "no longer exposes it" in caplog.text
|
||||
|
||||
|
||||
def test_external_platform_broken_dependency_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A missing dependency inside the external package must surface."""
|
||||
monkeypatch.setattr(
|
||||
platform_hooks,
|
||||
"import_module",
|
||||
Mock(side_effect=ModuleNotFoundError("not found", name="some_missing_dep")),
|
||||
)
|
||||
with pytest.raises(ModuleNotFoundError, match="not found"):
|
||||
platform_hooks.get_platform_hook("my_external_chip", "show_logs")
|
||||
|
||||
|
||||
def test_lookup_miss_does_not_import_platform_package(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The whole point: probing a platform without hooks must not import it."""
|
||||
monkeypatch.setattr(
|
||||
platform_hooks,
|
||||
"import_module",
|
||||
Mock(side_effect=AssertionError("platform package imported on registry miss")),
|
||||
)
|
||||
assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None
|
||||
|
||||
|
||||
def test_get_stacktrace_handler_resolves_registered_platform() -> None:
|
||||
hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32)
|
||||
from esphome.components import esp32
|
||||
|
||||
assert hook is esp32.process_stacktrace
|
||||
|
||||
|
||||
def test_get_stacktrace_handler_reports_missing_analyzer(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
||||
assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None
|
||||
assert "no compatible analyzer" in caplog.text
|
||||
# A capability gap is ordinary; it must not warn.
|
||||
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_get_stacktrace_handler_reports_import_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
platform_hooks,
|
||||
"import_module",
|
||||
Mock(side_effect=ImportError("broken install")),
|
||||
)
|
||||
assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None
|
||||
assert "failed to import: broken install" in caplog.text
|
||||
# A broken install is a real breakage; it must warn, not inform.
|
||||
assert any(r.levelno == logging.WARNING for r in caplog.records)
|
||||
@@ -0,0 +1,563 @@
|
||||
"""Tests for the shared extraScript machinery (platformio.extra_script)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio.extra_script import (
|
||||
CppDefine,
|
||||
ExtraScriptResult,
|
||||
_FakeSConsEnv,
|
||||
apply_extra_script,
|
||||
captured_as_build_flags,
|
||||
run_extra_script,
|
||||
)
|
||||
from esphome.platformio.library import (
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
ConvertedLibrary as IDFComponent,
|
||||
URLSource,
|
||||
lex_build_flags,
|
||||
)
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
assert result.libpath == [str(Path("src") / "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert CppDefine("BAR", "1") in result.cppdefines
|
||||
assert CppDefine("FOO") in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
# Lex like the consumer does: quoting makes raw strings platform-varying
|
||||
tokens = lex_build_flags(
|
||||
captured_as_build_flags(result, library_dir=tmp_path), "test"
|
||||
)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in tokens
|
||||
assert "-lalgobsec" in tokens
|
||||
assert "-DFOO" in tokens
|
||||
assert "-DBAR=1" in tokens
|
||||
# LINKFLAGS travel via the link-flags channel, never the compile flags
|
||||
assert "-Wl,--gc-sections" not in tokens
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert lex_build_flags(flags, "test") == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
with pytest.raises(EsphomeError, match="escapes the library directory"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path):
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_apply_extra_script_malformed_flags_raises(tmp_path) -> None:
|
||||
"""A null/dict build.flags fails naming the library instead of injecting
|
||||
a non-string into the compiler command line."""
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": None}}
|
||||
|
||||
with pytest.raises(EsphomeError, match="malformed build.flags"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
|
||||
|
||||
def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None:
|
||||
"""The shared helper resolves the board_mcu callable lazily and normalizes
|
||||
a string ``build.flags`` value into a list before extending it."""
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": "-DBASE=1"}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"]
|
||||
|
||||
|
||||
def test_captured_nonstring_buckets_warn_and_skip(tmp_path, caplog) -> None:
|
||||
"""Non-string LIBS/LINKFLAGS/CPPFLAGS/LIBPATH entries (legal SCons
|
||||
nodes) are skipped by name instead of stringified into garbage flags."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Append(LIBS=['m', 42], LINKFLAGS=['-Wl,-x', {'no': 1}], "
|
||||
"CPPFLAGS=['-Os', 3.5], LIBPATH=['libs', 7])\n"
|
||||
)
|
||||
(tmp_path / "libs").mkdir()
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
flags = c.data["build"]["flags"]
|
||||
assert "-lm" in flags and "-Os" in flags
|
||||
assert c.data[ESPHOME_DATA_KEY][ESPHOME_DATA_LINK_FLAGS_KEY] == ["-Wl,-x"]
|
||||
assert not any("42" in f or "no" in f or "3.5" in f for f in flags)
|
||||
assert "Ignoring unsupported LIBS entry 42" in caplog.text
|
||||
assert "Ignoring unsupported LINKFLAGS entry {'no': 1}" in caplog.text
|
||||
assert "Ignoring unsupported LIBPATH entry 7" in caplog.text
|
||||
|
||||
|
||||
def test_captured_dict_cppdefines_warn_and_skip(tmp_path, caplog) -> None:
|
||||
"""A dict CPPDEFINES entry (legal SCons) must warn and skip; formatting
|
||||
it blind would hand the compiler -D{'FOO': '1'} garbage."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Append(CPPDEFINES=[{'FOO': '1'}, ('BAR', 2), ['BAZ', 3], 'PLAIN'])\n"
|
||||
)
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-DBAR=2", "-DBAZ=3", "-DPLAIN"]
|
||||
assert "Ignoring unsupported CPPDEFINES entry" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_subscript_env_read(tmp_path) -> None:
|
||||
"""Scripts also read env["BOARD_MCU"]; the subscript form must work or
|
||||
the broad handler discards every flag the script captured."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env['BOARD_MCU']])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-lesp8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
|
||||
|
||||
# No extraScript declared: nothing happens, the target is never resolved
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {}}
|
||||
apply_extra_script(
|
||||
c,
|
||||
board_mcu=lambda: pytest.fail("target resolved without a script"),
|
||||
pio_platform="espressif8266",
|
||||
)
|
||||
|
||||
# A script that captures nothing leaves the flags untouched
|
||||
script = tmp_path / "noop.py"
|
||||
script.write_text("pass\n")
|
||||
c.data = {"build": {"extraScript": "noop.py"}}
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path, caplog) -> None:
|
||||
"""Un-captured env vars and unsupported env methods are skipped but
|
||||
diagnosable from the build log."""
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Replace(CC='clang')\nenv.Append(UNCAPTURED=['x'], LIBS='single')\n"
|
||||
)
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert c.data["build"]["flags"] == ["-lsingle"]
|
||||
assert "env.Append(UNCAPTURED=...) is not captured" in caplog.text
|
||||
assert "env.Replace is not supported" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
|
||||
"""A raising extra-script is best-effort: logged and skipped."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
assert "ignoring its output" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_pio_platform(tmp_path) -> None:
|
||||
"""The backend's platform token is exposed to the script as PIOPLATFORM."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env.get('PIOPLATFORM')])\n")
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert c.data["build"]["flags"] == ["-lespressif8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_missing_script_raises(tmp_path) -> None:
|
||||
"""A declared but absent extraScript is a broken package and fails by
|
||||
name, as it would under PlatformIO."""
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "nope.py"}}
|
||||
with pytest.raises(EsphomeError, match="nope.py of library owner/name not found"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", (["a.py"], {"esp32": "a.py"}), ids=("list", "dict"))
|
||||
def test_apply_extra_script_non_string_raises(tmp_path, bad) -> None:
|
||||
"""A non-string extraScript fails naming the library, not with a TypeError."""
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": bad}}
|
||||
with pytest.raises(EsphomeError, match="of library owner/name must be a string"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
|
||||
def test_extra_script_cpppath_captured_as_include_flags(tmp_path, monkeypatch):
|
||||
"""CPPPATH entries translate to -I flags anchored like LIBPATH."""
|
||||
|
||||
(tmp_path / "include").mkdir()
|
||||
outside = tmp_path.parent / "system_inc"
|
||||
outside.mkdir(exist_ok=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(cpppath=["include", str(outside), 7])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
assert lex_build_flags(flags, "test") == ["-Iinclude", f"-I{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_spaced_paths_survive_relexing(tmp_path):
|
||||
"""-I/-L paths with spaces round-trip through lex_build_flags as one token."""
|
||||
(tmp_path / "my libs").mkdir()
|
||||
result = ExtraScriptResult(cpppath=["my libs"], libpath=["my libs"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == ["-Imy libs", "-Lmy libs"]
|
||||
|
||||
|
||||
def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) -> None:
|
||||
"""A crashed script yields an empty result: half-applied flags could
|
||||
build wrong-output firmware that links cleanly."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\nraise RuntimeError('boom')\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "ignoring its output" in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_syntax_error_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""A vendored script that does not even compile warns and skips instead
|
||||
of aborting the build."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("def broken(:\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "ignoring its output" in caplog.text
|
||||
|
||||
|
||||
def test_unsupported_env_method_warns_once(caplog) -> None:
|
||||
"""Repeated calls to the same unsupported method warn only once."""
|
||||
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Replace(CC="clang")
|
||||
env.Replace(CC="gcc")
|
||||
assert caplog.text.count("env.Replace is not supported") == 1
|
||||
|
||||
|
||||
def test_run_extra_script_sys_exit_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""A nonzero sys.exit() in a vendored script must not kill the esphome
|
||||
run, and its output is discarded."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("import sys\nenv.Append(LIBS=['x'])\nsys.exit(3)\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "exited with status 3" in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_sys_exit_zero_is_success(tmp_path, caplog) -> None:
|
||||
"""sys.exit(0) is a normal PlatformIO script ending: the capture is kept."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("import sys\nenv.Append(LIBS=['algobsec'])\nsys.exit(0)\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == ["algobsec"]
|
||||
assert "ignoring its output" not in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_unreadable_raises(tmp_path) -> None:
|
||||
"""An unreadable declared script is a broken package, like a missing one."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("")
|
||||
with (
|
||||
patch("pathlib.Path.read_text", side_effect=OSError("denied")),
|
||||
pytest.raises(EsphomeError, match="is unreadable"),
|
||||
):
|
||||
run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
|
||||
def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""Undecodable content warns and skips, like a SyntaxError."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_bytes(b"\xff\xfe\x00bad")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "is not UTF-8" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ("Prepend", "AppendUnique", "PrependUnique"))
|
||||
def test_append_variants_capture_like_append(method: str) -> None:
|
||||
"""Prepend/AppendUnique/PrependUnique write the captured keys too."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
getattr(env, method)(LIBS=["algobsec"], LIBPATH=["lib"])
|
||||
assert env.result.libs == ["algobsec"]
|
||||
assert env.result.libpath == ["lib"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ("Prepend", "PrependUnique"))
|
||||
def test_prepend_inserts_ahead_of_existing(method: str) -> None:
|
||||
"""Prepend keeps SCons order: new values land ahead of what is already
|
||||
captured (scripts prepend LIBS for static-link symbol resolution)."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(LIBS=["m"])
|
||||
getattr(env, method)(LIBS=["algobsec", "bsec"])
|
||||
assert env.result.libs == ["algobsec", "bsec", "m"]
|
||||
|
||||
|
||||
def test_env_membership_and_iteration(tmp_path) -> None:
|
||||
"""Membership tests and for-loops must use the mapping protocol; the
|
||||
legacy sequence fallback through __getitem__ would loop forever."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert "BOARD_MCU" in env
|
||||
assert "NOPE" not in env
|
||||
assert sorted(env) == ["BOARD_MCU", "PIOENV", "PIOPLATFORM"]
|
||||
|
||||
|
||||
def test_apply_extra_script_non_string_falsey_raises(tmp_path) -> None:
|
||||
"""A falsey non-string extraScript (false, 0, []) is a malformed
|
||||
manifest, not an absent script."""
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": False}}
|
||||
with pytest.raises(EsphomeError, match="must be a string"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
|
||||
def test_env_get_unknown_key_warns_once(caplog) -> None:
|
||||
"""A script branching on an unmodelled env var is diagnosable."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env.get("BOARD") is None
|
||||
assert env.get("BOARD", "d1") == "d1"
|
||||
assert env.get("BOARD_MCU") == "esp8266"
|
||||
assert caplog.text.count("env.get('BOARD') is not modelled") == 1
|
||||
assert "BOARD_MCU" not in caplog.text
|
||||
|
||||
|
||||
def test_spaced_cppflag_survives_relexing(tmp_path) -> None:
|
||||
"""A captured argv token with a space stays one token after lexing."""
|
||||
result = ExtraScriptResult(
|
||||
cppflags=["-include my hdr.h"],
|
||||
cppdefines=[CppDefine("MSG", '"hello world"'), CppDefine("PLAIN")],
|
||||
)
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [
|
||||
'-DMSG="hello world"',
|
||||
"-DPLAIN",
|
||||
"-include my hdr.h",
|
||||
]
|
||||
|
||||
|
||||
def test_env_attribute_access_warns_without_call(caplog) -> None:
|
||||
"""hasattr()/truthiness on an unsupported method is diagnosable; dunder
|
||||
protocol probes stay silent."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env.GetProjectOption
|
||||
assert caplog.text.count("env.GetProjectOption is not supported") == 1
|
||||
assert not hasattr(env, "__deepcopy__")
|
||||
assert "__deepcopy__" not in caplog.text
|
||||
|
||||
|
||||
def test_env_unmodelled_subscript_degrades_one_branch(caplog) -> None:
|
||||
"""env[...] on an unmodelled var returns '' instead of KeyError
|
||||
discarding the whole capture."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env["PIOFRAMEWORK"] == ""
|
||||
assert env["PIOFRAMEWORK"] == ""
|
||||
assert caplog.text.count("env['PIOFRAMEWORK'] is not modelled") == 1
|
||||
assert env["BOARD_MCU"] == "esp8266"
|
||||
env.Append(LIBS=["still_captured"])
|
||||
assert env.result.libs == ["still_captured"]
|
||||
|
||||
|
||||
def test_cppdefines_scons_spellings(tmp_path) -> None:
|
||||
"""A bare 2-tuple is one name=value pair, a dict maps names to values,
|
||||
and a None value is a bare define (SCons processDefines)."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(CPPDEFINES=("FOO", "1"))
|
||||
env.Append(CPPDEFINES={"BAR": "2", "BAZ": None})
|
||||
env.Append(CPPDEFINES=["PLAIN"])
|
||||
flags = captured_as_build_flags(env.result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [
|
||||
"-DFOO=1",
|
||||
"-DBAR=2",
|
||||
"-DBAZ",
|
||||
"-DPLAIN",
|
||||
]
|
||||
|
||||
|
||||
def test_uncaptured_append_key_warns_once(caplog) -> None:
|
||||
"""A loop of Appends to the same uncaptured key warns once."""
|
||||
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(RANLIBFLAGS=["a"])
|
||||
env.Append(RANLIBFLAGS=["b"])
|
||||
assert caplog.text.count("env.Append(RANLIBFLAGS=...) is not captured") == 1
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
"""Tests for esphome.platformio.registry (PIO-registry package installs)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio import registry
|
||||
|
||||
|
||||
def test_registry_download_resolves_once_per_process() -> None:
|
||||
"""The prefetch and the install share one metadata resolve per package."""
|
||||
calls: list[dict] = []
|
||||
payload = {
|
||||
"versions": [
|
||||
{
|
||||
"name": "1.0.0",
|
||||
"files": [
|
||||
{
|
||||
"download_url": "http://x/pkg.tar.gz",
|
||||
"checksum": {"sha256": "ab" * 32},
|
||||
"size": 5,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
calls.append(url)
|
||||
return _http_response(json.dumps(payload))
|
||||
|
||||
with patch.object(registry, "http_request", side_effect=fake_request):
|
||||
first = registry.registry_download("o/pkg", "1.0.0")
|
||||
second = registry.registry_download("o/pkg", "1.0.0")
|
||||
assert first == second
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_registry_cache():
|
||||
# registry_download memoizes per process; tests reuse package names
|
||||
registry.registry_download.cache_clear()
|
||||
yield
|
||||
registry.registry_download.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "expected"),
|
||||
[
|
||||
("Darwin", "arm64", "darwin_arm64"),
|
||||
("Darwin", "x86_64", "darwin_x86_64"),
|
||||
("Windows", "AMD64", "windows_amd64"),
|
||||
# Deviation from upstream: auto-mapped to the emulated-x86 packages
|
||||
("Windows", "ARM64", "windows_amd64"),
|
||||
("Windows", "x86", "windows_x86"),
|
||||
("Linux", "x86_64", "linux_x86_64"),
|
||||
("Linux", "aarch64", "linux_aarch64"),
|
||||
("Linux", "i686", "linux_i686"),
|
||||
("Linux", "armv7l", "linux_armv7l"),
|
||||
# Unknown hosts pass through like upstream; the registry lookup
|
||||
# then fails naming the tag
|
||||
("FreeBSD", "amd64", "freebsd_amd64"),
|
||||
],
|
||||
)
|
||||
def test_get_systype(system: str, machine: str, expected: str) -> None:
|
||||
with (
|
||||
patch("platform.system", return_value=system),
|
||||
patch("platform.machine", return_value=machine),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == expected
|
||||
|
||||
|
||||
def test_get_systype_env_override() -> None:
|
||||
"""PLATFORMIO_SYSTEM_TYPE wins, exactly as in upstream get_systype()."""
|
||||
with patch.dict(os.environ, {"PLATFORMIO_SYSTEM_TYPE": "windows_amd64"}):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def test_get_systype_aarch64_32bit_userland() -> None:
|
||||
"""A 32-bit userland on a 64-bit arm kernel gets armv7l binaries."""
|
||||
with (
|
||||
patch("platform.system", return_value="Linux"),
|
||||
patch("platform.machine", return_value="aarch64"),
|
||||
patch("platform.architecture", return_value=("32bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "linux_armv7l"
|
||||
|
||||
|
||||
def test_get_systype_windows_empty_machine() -> None:
|
||||
"""An empty machine string falls back to the architecture bits."""
|
||||
with (
|
||||
patch("platform.system", return_value="Windows"),
|
||||
patch("platform.machine", return_value=""),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def _http_response(text: str) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _registry_response(files: list[dict]):
|
||||
"""Patch the consolidated HTTP path to serve a canned registry response."""
|
||||
payload = {"versions": [{"name": "1.0.0", "files": files}]}
|
||||
return patch.object(
|
||||
registry, "http_request", return_value=_http_response(json.dumps(payload))
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_uses_shared_http_path() -> None:
|
||||
"""The metadata fetch delegates to the consolidated http_request path;
|
||||
request failures surface as a named EsphomeError."""
|
||||
import requests as req
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
side_effect=req.exceptions.ConnectionError("registry down"),
|
||||
) as mock_request,
|
||||
pytest.raises(EsphomeError, match="Could not fetch registry metadata"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
(method, url), _ = mock_request.call_args
|
||||
assert method == "GET"
|
||||
assert url == registry._REGISTRY_URL.format(package="pkg")
|
||||
|
||||
|
||||
def test_registry_download_invalid_json_is_clean() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response("<html>not json</html>"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="invalid JSON"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_matches_system() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": ["windows_amd64"], "download_url": "http://x/win"},
|
||||
{
|
||||
"system": ["linux_x86_64"],
|
||||
"download_url": "http://x/linux",
|
||||
"checksum": {"sha256": "abc123"},
|
||||
"size": 42,
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/linux",
|
||||
"abc123",
|
||||
42,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_bare_string_system() -> None:
|
||||
"""A bare-string system tag is an exact match, not a substring test."""
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": "linux_x86", "download_url": "http://x/x86"},
|
||||
{
|
||||
"system": "linux_x86_64",
|
||||
"download_url": "http://x/x86_64",
|
||||
"checksum": {"sha256": "abc"},
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0")[0] == "http://x/x86_64"
|
||||
|
||||
|
||||
def test_registry_download_wildcard_system() -> None:
|
||||
with _registry_response(
|
||||
[
|
||||
{
|
||||
"system": "*",
|
||||
"download_url": "http://x/any",
|
||||
"checksum": {"sha256": "abc"},
|
||||
"size": 7,
|
||||
}
|
||||
]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/any",
|
||||
"abc",
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_missing_checksum_raises() -> None:
|
||||
"""An unverifiable archive is refused, never silently extracted."""
|
||||
with (
|
||||
_registry_response([{"system": "*", "download_url": "http://x/any"}]),
|
||||
pytest.raises(EsphomeError, match="no sha256"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_no_system_match() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_version_not_found() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(
|
||||
json.dumps({"versions": [{"name": "2.0.0", "files": []}]})
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="not found"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
(dest / "payload").mkdir(parents=True)
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with patch.object(registry, "download_from_mirrors") as mock_download:
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_marker_hit_rechecks_layout(tmp_path: Path) -> None:
|
||||
"""A marked install that later lost files fails by name instead of
|
||||
surfacing as an opaque toolchain error."""
|
||||
dest = tmp_path / "pkg"
|
||||
dest.mkdir()
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with pytest.raises(EsphomeError, match="missing the expected payload"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
|
||||
|
||||
def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"]
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
# Extraction is expected to create the directory
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, mirrors, tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] is mirrors
|
||||
assert mock_download.call_args[0][1] == {
|
||||
"VERSION": "1.0.0",
|
||||
"SYSTEM": "linux_x86_64",
|
||||
}
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
|
||||
"""The registry path downloads with the registry's sha256 and size."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
return_value=("http://x/pkg.tar.gz", "abc123", 42),
|
||||
),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
|
||||
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
|
||||
|
||||
|
||||
def test_install_package_validates_expected_layout(tmp_path: Path) -> None:
|
||||
"""The success marker is only written when the extracted tree is usable."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "bin").mkdir(parents=True)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_unexpected_layout_raises(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="missing the expected bin"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert not (dest / ".esphome_extracted").exists()
|
||||
|
||||
|
||||
def test_install_package_marker_rechecked_under_lock(tmp_path: Path) -> None:
|
||||
"""A concurrent install finishing while we wait for the lock is detected."""
|
||||
dest = tmp_path / "pkg"
|
||||
marker = dest / ".esphome_extracted"
|
||||
|
||||
@contextmanager
|
||||
def _fake_lock(*_a, **_kw):
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
marker.touch()
|
||||
yield
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock", _fake_lock),
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "rmdir") as mock_rmdir,
|
||||
):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
mock_rmdir.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_uses_hard_lock(tmp_path: Path) -> None:
|
||||
"""The install lock must never degrade to a soft (existence) lock."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch("filelock.FileLock") as mock_lock,
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_lock.call_args.kwargs["fallback_to_soft"] is False
|
||||
|
||||
|
||||
def test_registry_download_empty_system_list_does_not_match() -> None:
|
||||
"""An explicitly empty system list must not act as a wildcard."""
|
||||
with (
|
||||
_registry_response([{"system": [], "download_url": "http://x/any"}]),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_unexpected_payload_is_named() -> None:
|
||||
"""An error envelope without a versions list is not 'version not found'."""
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps({"message": "rate limited"})),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_system_key_matches_any() -> None:
|
||||
"""A file with no system key at all serves every host."""
|
||||
with _registry_response(
|
||||
[{"download_url": "http://x/any", "checksum": {"sha256": "abc"}, "size": 1}]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == ("http://x/any", "abc", 1)
|
||||
|
||||
|
||||
def test_registry_download_missing_files_list_is_named() -> None:
|
||||
"""A version entry without a files list is an unexpected payload, not a
|
||||
missing platform build."""
|
||||
with (
|
||||
_registry_response(None),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_download_url_is_named() -> None:
|
||||
with (
|
||||
_registry_response([{"system": "*", "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="no download URL"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_empty_expect_rejected(tmp_path: Path) -> None:
|
||||
"""Layout validation is the only guard before marker.touch(), so an
|
||||
empty expect is a caller bug, not a lenient install."""
|
||||
with pytest.raises(ValueError, match="non-empty expect"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", tmp_path / "pkg", [], tmp_path / "dl", expect=()
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_non_dict_version_entry_is_named() -> None:
|
||||
"""A versions list of bare strings is an unexpected payload, not an
|
||||
AttributeError traceback."""
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps({"versions": ["1.0.0", "2.0.0"]})),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_file_entry_is_named() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(
|
||||
json.dumps({"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]})
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_payload_is_named() -> None:
|
||||
"""A JSON array answer is an unexpected payload at the outermost level."""
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps(["1.0.0"])),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_list_system_is_named() -> None:
|
||||
"""A system field that is neither missing, str, nor list is an
|
||||
unexpected payload, not a TypeError from the ``in`` test."""
|
||||
with (
|
||||
_registry_response([{"system": 5, "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def _resolve_for(sizes: dict[str, int | None]):
|
||||
def resolve(name: str, version: str):
|
||||
size = sizes[name]
|
||||
if size == -1:
|
||||
raise EsphomeError("registry down")
|
||||
return (f"http://x/{name}.tar.gz", "abc123", size)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None:
|
||||
"""Two uninstalled packages download together under one combined bar,
|
||||
with the registry's sha256 and size and a batch progress tracker."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert mock_download.call_count == 2
|
||||
# Locking makes worker completion order nondeterministic
|
||||
calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0])
|
||||
for call, (name, version, size) in zip(
|
||||
calls, [("a", "1.0", 10), ("b", "2.0", 20)], strict=True
|
||||
):
|
||||
assert call[0][0] == f"http://x/{name}.tar.gz"
|
||||
assert call[0][1] == tmp_path / "dl" / f"{name}-{version}"
|
||||
assert call[1]["sha256"] == "abc123"
|
||||
assert call[1]["size"] == size
|
||||
assert callable(call[1]["progress"])
|
||||
|
||||
|
||||
def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None:
|
||||
"""A dest whose marker appeared while the worker waited on the lock is
|
||||
already installed; re-downloading would orphan an archive copy."""
|
||||
dest = tmp_path / "a"
|
||||
dest.mkdir()
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def marker_appears_under_lock(path, **kwargs):
|
||||
# Simulates the concurrent build finishing while we waited
|
||||
(dest / ".esphome_extracted").touch()
|
||||
yield
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock", side_effect=marker_appears_under_lock),
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages([("a", "1.0", dest, [])], tmp_path / "dl")
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_already_installed_probe(tmp_path: Path) -> None:
|
||||
"""Both arms of the marker probe the prefetch worker keys on."""
|
||||
dest = tmp_path / "pkg"
|
||||
dest.mkdir()
|
||||
assert registry._already_installed(dest) is False
|
||||
(dest / ".esphome_extracted").touch()
|
||||
assert registry._already_installed(dest) is True
|
||||
|
||||
|
||||
def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None:
|
||||
"""Duplicate (name, version) entries would race each other between two
|
||||
workers; only one survives (and one is too few to parallelize)."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None:
|
||||
"""One pending package has nothing to parallelize; the sequential
|
||||
install keeps its own bar."""
|
||||
marker_dest = tmp_path / "a"
|
||||
marker_dest.mkdir()
|
||||
(marker_dest / ".esphome_extracted").touch()
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", marker_dest, []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_mirror_and_sizeless_stay_sequential(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Mirror overrides and size-less registry entries are left to the
|
||||
sequential path so its per-file bars stay trustworthy."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
side_effect=_resolve_for({"b": None, "c": 30}),
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("c", "3.0", tmp_path / "c", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_resolve_failure_defers_to_install(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A registry failure only skips the prefetch; install_package reports
|
||||
the real error with context."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": -1, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
assert "Prefetch resolve for a failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None:
|
||||
"""An archive already fully downloaded is not re-fetched."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x" * 10)
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
dl,
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_download_failure_is_debug(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed prefetch download is logged and left for install_package."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(
|
||||
registry, "download_with_resume", side_effect=OSError("boom")
|
||||
) as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert mock_download.call_count == 2
|
||||
assert "Prefetch of a failed" in caplog.text
|
||||
assert "Prefetch of b failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_packages_unexpected_failure_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A programming error (not a download failure) surfaces at WARNING
|
||||
instead of becoming a permanent silent no-op."""
|
||||
with (
|
||||
patch.object(
|
||||
registry, "download_with_resume", side_effect=TypeError("bad call")
|
||||
),
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert "TypeError" in caplog.text
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for esphome.platformio.runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import io
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.platformio import runner
|
||||
|
||||
|
||||
def _prepare_main(
|
||||
monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int]
|
||||
) -> io.BytesIO:
|
||||
"""Point ``runner.main()`` at a fake PlatformIO with a fake stdout.
|
||||
|
||||
The real ``main`` patches PlatformIO internals and then hands control to
|
||||
it; both are stubbed out so only the stream wrapping is exercised. The
|
||||
fake stdout is block buffered like a pipe, so the caller can see what
|
||||
actually left the wrapper.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["pio", "run"])
|
||||
monkeypatch.setattr(sys, "stdout", stream)
|
||||
monkeypatch.setattr(sys, "stderr", stream)
|
||||
monkeypatch.setattr(runner, "patch_structhash", lambda: None)
|
||||
monkeypatch.setattr(runner, "patch_file_downloader", lambda: None)
|
||||
|
||||
platformio = ModuleType("platformio")
|
||||
platformio_main = ModuleType("platformio.__main__")
|
||||
platformio_main.main = pio_main # type: ignore[attr-defined]
|
||||
platformio.__main__ = platformio_main # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "platformio", platformio)
|
||||
monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main)
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
def test_main_drains_a_partial_line_on_a_clean_run(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A build ending mid line still shows that line."""
|
||||
|
||||
def pio_main() -> int:
|
||||
print("Linking .pioenvs/firmware.elf\n", end="")
|
||||
print("Building took 12.4 seconds", end="")
|
||||
return 0
|
||||
|
||||
buf = _prepare_main(monkeypatch, pio_main)
|
||||
|
||||
assert runner.main() == 0
|
||||
assert buf.getvalue().decode("utf-8") == (
|
||||
"Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n"
|
||||
)
|
||||
|
||||
|
||||
def test_main_drains_when_platformio_exits_early(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Leaving through ``sys.exit`` still drains, because it runs in a finally."""
|
||||
|
||||
def pio_main() -> int:
|
||||
print("*** [.pioenvs/firmware.elf] Error 1", end="")
|
||||
sys.exit(1)
|
||||
|
||||
buf = _prepare_main(monkeypatch, pio_main)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
runner.main()
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n"
|
||||
|
||||
|
||||
def test_main_still_filters_a_drained_partial_line(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Releasing a held line does not smuggle noise past the filter."""
|
||||
|
||||
def pio_main() -> int:
|
||||
# Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator.
|
||||
print("Verbose mode can be enabled via `-v, --verbose` option", end="")
|
||||
return 0
|
||||
|
||||
buf = _prepare_main(monkeypatch, pio_main)
|
||||
|
||||
assert runner.main() == 0
|
||||
assert buf.getvalue() == b""
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.platformio import runner, toolchain
|
||||
from esphome.util import FlashImage
|
||||
from esphome.util import ESP32_ARDUINO_ENV, FlashImage
|
||||
|
||||
|
||||
def test_idedata_firmware_elf_path(setup_core: Path) -> None:
|
||||
@@ -276,15 +280,116 @@ def test_run_idedata_raises_on_no_json(
|
||||
def test_run_idedata_raises_on_invalid_json(
|
||||
setup_core: Path, mock_run_platformio_cli_run: Mock
|
||||
) -> None:
|
||||
"""Test _run_idedata raises on malformed JSON."""
|
||||
"""Malformed JSON is the environment (garbage stdout), so it must
|
||||
surface as EsphomeError and get the recompile hint downstream.
|
||||
"""
|
||||
config = {"name": "test"}
|
||||
mock_run_platformio_cli_run.return_value = '{"invalid": json"}'
|
||||
|
||||
# The ValueError from json.loads is re-raised
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(EsphomeError):
|
||||
toolchain._run_idedata(config)
|
||||
|
||||
|
||||
def test_run_idedata_raises_on_launch_failure(
|
||||
setup_core: Path, mock_run_platformio_cli_run: Mock
|
||||
) -> None:
|
||||
"""A failed platformio launch returns its exit code as an int; that
|
||||
must surface as EsphomeError, not a TypeError from re.search.
|
||||
"""
|
||||
config = {"name": "test"}
|
||||
mock_run_platformio_cli_run.return_value = 1
|
||||
|
||||
with pytest.raises(EsphomeError):
|
||||
toolchain._run_idedata(config)
|
||||
|
||||
|
||||
def test_idedata_missing_prog_path_raises_esphome_error(setup_core: Path) -> None:
|
||||
"""A stale cached idedata JSON without prog_path is the build tree's
|
||||
fault; it must surface as EsphomeError, not a KeyError.
|
||||
"""
|
||||
with pytest.raises(EsphomeError):
|
||||
_ = toolchain.IDEData({}).firmware_elf_path
|
||||
|
||||
|
||||
def test_idedata_missing_flash_image_field_raises_esphome_error(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A cached idedata whose flash image entries lost a field must
|
||||
classify as an environment error too, not a raw KeyError.
|
||||
"""
|
||||
idedata = toolchain.IDEData({"extra": {"flash_images": [{"offset": "0x1000"}]}})
|
||||
with pytest.raises(EsphomeError):
|
||||
_ = idedata.extra_flash_images
|
||||
|
||||
|
||||
def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None:
|
||||
"""A section that is null instead of absent must classify the same
|
||||
as a missing key instead of escaping as TypeError.
|
||||
"""
|
||||
with pytest.raises(EsphomeError):
|
||||
_ = toolchain.IDEData({"extra": None}).extra_flash_images
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "framework", "expected"),
|
||||
[
|
||||
("esp32", "arduino", "1"),
|
||||
("esp32", "esp-idf", None),
|
||||
("esp8266", "arduino", None),
|
||||
],
|
||||
)
|
||||
def test_run_platformio_cli_flags_an_esp32_arduino_build(
|
||||
setup_core: Path,
|
||||
mock_run_external_process: Mock,
|
||||
platform: str,
|
||||
framework: str,
|
||||
expected: str | None,
|
||||
) -> None:
|
||||
"""Only an ESP32 Arduino build is flagged, and an inherited one is cleared."""
|
||||
CORE.build_path = str(setup_core / "build" / "test")
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: platform,
|
||||
KEY_TARGET_FRAMEWORK: framework,
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
|
||||
mock_run_external_process.return_value = 0
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
env = mock_run_external_process.call_args[1]["env"]
|
||||
assert env.get(ESP32_ARDUINO_ENV) == expected
|
||||
# Only the subprocess env is touched; ours is left as it was.
|
||||
assert os.environ[ESP32_ARDUINO_ENV] == "1"
|
||||
|
||||
|
||||
def test_run_platformio_cli_ignores_an_inherited_flag_without_core(
|
||||
setup_core: Path, mock_run_external_process: Mock
|
||||
) -> None:
|
||||
"""An inherited flag must not end up answering for CORE."""
|
||||
CORE.build_path = str(setup_core / "build" / "test")
|
||||
CORE.data.pop(KEY_CORE, None)
|
||||
|
||||
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
|
||||
mock_run_external_process.return_value = 0
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
env = mock_run_external_process.call_args[1]["env"]
|
||||
assert ESP32_ARDUINO_ENV not in env
|
||||
|
||||
|
||||
def test_run_platformio_cli_raises_on_a_half_filled_core(
|
||||
setup_core: Path, mock_run_external_process: Mock
|
||||
) -> None:
|
||||
"""A CORE set up but left incomplete must surface, not fall back."""
|
||||
CORE.build_path = str(setup_core / "build" / "test")
|
||||
CORE.data[KEY_CORE] = {}
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
mock_run_external_process.return_value = 0
|
||||
with pytest.raises(KeyError):
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
|
||||
def test_run_platformio_cli_sets_environment_variables(
|
||||
setup_core: Path, mock_run_external_process: Mock
|
||||
) -> None:
|
||||
@@ -320,38 +425,420 @@ 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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
|
||||
assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
("env_vars", "expect_warning"),
|
||||
[
|
||||
# win32: drive-letter extended-length prefix is stripped
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||
"\\\\server\\share\\python.exe",
|
||||
),
|
||||
# win32: paths without the prefix are returned unchanged
|
||||
(
|
||||
"win32",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# non-win32: prefix is left alone (no-op)
|
||||
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||
pytest.param({}, False, id="default"),
|
||||
pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"),
|
||||
],
|
||||
)
|
||||
def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
def test_ccache_env_disabled_without_binary(
|
||||
setup_core: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
env_vars: dict[str, str],
|
||||
expect_warning: bool,
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.platformio.toolchain.sys.platform", platform):
|
||||
assert toolchain._strip_win_long_path_prefix(input_path) == expected
|
||||
"""Ccache stays off when the binary is not on PATH, even when forced on.
|
||||
|
||||
A deliberate opt-in that finds no binary is downgraded with a warning so
|
||||
the user can tell why it had no effect; the default path stays quiet.
|
||||
"""
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, env_vars, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
assert ("no ccache binary is on PATH" in caplog.text) is expect_warning
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"probe_error",
|
||||
[
|
||||
pytest.param(OSError("not runnable"), id="oserror"),
|
||||
pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"),
|
||||
pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"),
|
||||
],
|
||||
)
|
||||
def test_ccache_env_disabled_when_probe_fails(
|
||||
setup_core: Path, probe_error: Exception
|
||||
) -> None:
|
||||
"""A ccache that resolves on PATH but fails to run stays disabled."""
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=probe_error),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
|
||||
|
||||
def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
|
||||
"""An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary."""
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
|
||||
# The binary's location is still handed to the build script.
|
||||
assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
|
||||
mock_probe.assert_not_called()
|
||||
|
||||
|
||||
def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None:
|
||||
r"""A ``\\?\`` ccache path from PATH is exported without the prefix.
|
||||
|
||||
That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``.
|
||||
"""
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
prefixed = (
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder"
|
||||
"\\ccache\\ccache.exe"
|
||||
)
|
||||
stripped = (
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
# shutil.which is patched, so the win32 code path of the real
|
||||
# implementation (which crashes on a POSIX host) is never reached.
|
||||
patch("esphome.framework_helpers.sys.platform", "win32"),
|
||||
patch("shutil.which", return_value=prefixed),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
|
||||
assert env["ESPHOME_CCACHE_PATH"] == stripped
|
||||
# The probe validates the exact string the build will execute.
|
||||
assert mock_probe.call_args[0][0] == [stripped, "--version"]
|
||||
|
||||
|
||||
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("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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
):
|
||||
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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
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["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
|
||||
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
|
||||
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
|
||||
assert "ESPHOME_CCACHE_PATH" 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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
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("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
class _FakeSConsEnv(dict):
|
||||
"""Just enough of a SCons construction environment for ccache.py."""
|
||||
|
||||
def Replace(self, **kwargs: object) -> None: # noqa: N802
|
||||
self.update(kwargs)
|
||||
|
||||
|
||||
def _load_ccache_script(
|
||||
env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None
|
||||
) -> tuple[_FakeSConsEnv, Callable[..., int]]:
|
||||
"""Run ccache.py.script against a fake SCons env and return (env, original SPAWN)."""
|
||||
if original_spawn is None:
|
||||
original_spawn = Mock(name="original_spawn", return_value=0)
|
||||
scons_env = _FakeSConsEnv(SPAWN=original_spawn)
|
||||
source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text()
|
||||
with patch.dict(os.environ, env_vars, clear=True):
|
||||
exec( # noqa: S102
|
||||
compile(source, "ccache.py", "exec"),
|
||||
{"Import": lambda *_names: None, "env": scons_env},
|
||||
)
|
||||
return scons_env, original_spawn
|
||||
|
||||
|
||||
def _scons_win32_escape(x: str) -> str:
|
||||
"""Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash."""
|
||||
if x[-1] == "\\":
|
||||
x = x + "\\"
|
||||
return '"' + x + '"'
|
||||
|
||||
|
||||
def test_ccache_script_wraps_compiles_with_exported_path() -> None:
|
||||
"""The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup."""
|
||||
ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe"
|
||||
scons_env, original_spawn = _load_ccache_script(
|
||||
{"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path}
|
||||
)
|
||||
spawn = scons_env["SPAWN"]
|
||||
assert spawn is not original_spawn
|
||||
|
||||
# A compile step is routed through ccache, with the same path used for
|
||||
# the program and (escaped) as the first argument.
|
||||
compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"]
|
||||
spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {})
|
||||
original_spawn.assert_called_once_with(
|
||||
"cmd.exe",
|
||||
_scons_win32_escape,
|
||||
ccache_path,
|
||||
[_scons_win32_escape(ccache_path), *compile_args],
|
||||
{},
|
||||
)
|
||||
|
||||
# Link steps pass through untouched.
|
||||
original_spawn.reset_mock()
|
||||
link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"]
|
||||
spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {})
|
||||
original_spawn.assert_called_once_with(
|
||||
"cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_vars",
|
||||
[
|
||||
pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"),
|
||||
pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"),
|
||||
pytest.param({}, id="unset"),
|
||||
],
|
||||
)
|
||||
def test_ccache_script_leaves_spawn_alone_without_path(
|
||||
env_vars: dict[str, str],
|
||||
) -> None:
|
||||
"""Without both the enable flag and a path, SPAWN is not replaced."""
|
||||
scons_env, original_spawn = _load_ccache_script(env_vars)
|
||||
assert scons_env["SPAWN"] is original_spawn
|
||||
|
||||
|
||||
def _scons_win32_spawn(
|
||||
sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict
|
||||
) -> int:
|
||||
r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``.
|
||||
|
||||
SCons is not importable in the test environment (PlatformIO fetches it at
|
||||
build time), so the lines that matter are mirrored here. The command line
|
||||
SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess``
|
||||
instead (identical on Windows, where a string passes through untouched);
|
||||
``spawnve`` itself crashes inside pytest.
|
||||
"""
|
||||
return subprocess.run(
|
||||
" ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False
|
||||
).returncode
|
||||
|
||||
|
||||
_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER"
|
||||
# Stands in for a compile: the "ccache" is really the Python interpreter, and
|
||||
# the compile "flags" make it write a marker file so the test can tell whether
|
||||
# the wrapped command actually ran to completion.
|
||||
_FAKE_COMPILE_ARGS = [
|
||||
"-c",
|
||||
f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')",
|
||||
]
|
||||
|
||||
|
||||
def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int:
|
||||
"""Run one wrapped compile step the way SCons does on Windows."""
|
||||
child_env = {**os.environ, _MARKER_ENV: str(marker)}
|
||||
return scons_env["SPAWN"](
|
||||
os.environ.get("COMSPEC", "cmd.exe"),
|
||||
_scons_win32_escape,
|
||||
"xtensa-lx106-elf-gcc",
|
||||
[_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS],
|
||||
child_env,
|
||||
)
|
||||
|
||||
|
||||
_WINDOWS_ONLY = pytest.mark.skipif(
|
||||
sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows"
|
||||
)
|
||||
|
||||
|
||||
@_WINDOWS_ONLY
|
||||
def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None:
|
||||
r"""With a ``\\?\`` which result, the real probe runs the stripped binary.
|
||||
|
||||
The probe therefore validates the exact string the build will execute
|
||||
through ``cmd.exe``; probing the verbatim path instead would pass even
|
||||
when the stripped path is unusable (``CreateProcess`` accepts
|
||||
extended-length paths, ``cmd.exe`` does not).
|
||||
"""
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
assert not sys.executable.startswith("\\\\?\\")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("shutil.which", return_value="\\\\?\\" + sys.executable),
|
||||
):
|
||||
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
|
||||
assert env["ESPHOME_CCACHE_PATH"] == sys.executable
|
||||
|
||||
|
||||
@_WINDOWS_ONLY
|
||||
@pytest.mark.parametrize(
|
||||
("prefix", "expect_ok"),
|
||||
[
|
||||
pytest.param("", True, id="stripped-path-compiles"),
|
||||
pytest.param("\\\\?\\", False, id="verbatim-path-fails"),
|
||||
],
|
||||
)
|
||||
def test_ccache_wrapper_through_cmd_exe(
|
||||
tmp_path: Path, prefix: str, expect_ok: bool
|
||||
) -> None:
|
||||
r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not.
|
||||
|
||||
The interpreter stands in for ccache; the spawn mirrors SCons on Windows.
|
||||
The failing case is the mechanism behind #18399 ("The system cannot find
|
||||
the path specified." on every compile step); should it ever start passing,
|
||||
``cmd.exe`` learned extended-length paths and the strip is no longer needed.
|
||||
"""
|
||||
marker = tmp_path / "compiled.txt"
|
||||
scons_env, _ = _load_ccache_script(
|
||||
{"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable},
|
||||
original_spawn=_scons_win32_spawn,
|
||||
)
|
||||
assert scons_env["SPAWN"] is not _scons_win32_spawn
|
||||
|
||||
rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker)
|
||||
assert (rc == 0) is expect_ok
|
||||
assert marker.exists() is expect_ok
|
||||
if expect_ok:
|
||||
assert marker.read_text() == "compiled"
|
||||
|
||||
|
||||
def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
@@ -373,8 +860,11 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
# 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.framework_helpers.sys.platform", "win32"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
|
||||
):
|
||||
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
|
||||
@@ -406,7 +896,7 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "linux"),
|
||||
patch("esphome.framework_helpers.sys.platform", "linux"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", plain_exe),
|
||||
):
|
||||
os.environ.pop("PYTHONEXEPATH", None)
|
||||
@@ -442,8 +932,13 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non
|
||||
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
|
||||
mock_run_platformio_cli_run.return_value = 0
|
||||
|
||||
toolchain.run_compile(config, verbose=True)
|
||||
with patch(
|
||||
"esphome.platformio.prefetch.prefetch_platformio_packages"
|
||||
) as mock_prefetch:
|
||||
toolchain.run_compile(config, verbose=True)
|
||||
|
||||
# The only wiring of the prefetch into a build lives here
|
||||
mock_prefetch.assert_called_once_with()
|
||||
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
|
||||
|
||||
|
||||
@@ -457,7 +952,8 @@ def test_run_compile_without_process_limit(
|
||||
config = {CONF_ESPHOME: {}}
|
||||
mock_run_platformio_cli_run.return_value = 0
|
||||
|
||||
toolchain.run_compile(config, verbose=False)
|
||||
with patch("esphome.platformio.prefetch.prefetch_platformio_packages"):
|
||||
toolchain.run_compile(config, verbose=False)
|
||||
|
||||
mock_run_platformio_cli_run.assert_called_once_with(config, False)
|
||||
|
||||
@@ -1093,3 +1589,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,239 @@
|
||||
"""Tests to verify preference and entity key hash values remain stable.
|
||||
|
||||
These tests ensure the hash algorithms do NOT change, as any change would cause
|
||||
users to lose stored preferences (calibration values, restore states, etc.) on
|
||||
firmware upgrades, or break entity state routing to API clients.
|
||||
|
||||
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
|
||||
1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1).
|
||||
The entity key sent to API clients and the base of every stored preference key.
|
||||
2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta
|
||||
firmware stored preferences under keys derived from it; a future key migration
|
||||
must reconstruct those keys to recover that data.
|
||||
|
||||
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
|
||||
the change breaks backward compatibility and will cause data loss.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import (
|
||||
FNV1_OFFSET_BASIS,
|
||||
FNV1_PRIME,
|
||||
fnv1_hash_name,
|
||||
fnv1_hash_object_id,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test: fnv1_hash_object_id produces stable hashes for entity names
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_name", "expected_object_id_hash"),
|
||||
[
|
||||
# =====================================================================
|
||||
# Core entity types - these names appear in many ESPHome configurations
|
||||
# =====================================================================
|
||||
# Basic single-word names
|
||||
("Light", 0x735CF023),
|
||||
("Switch", 0xBEDF78E5),
|
||||
("Sensor", 0x75E61B1B),
|
||||
("Fan", 0x468F6780),
|
||||
("Climate", 0xAA22FD4A),
|
||||
("Cover", 0xA630D0A2),
|
||||
("Lock", 0x1D2FD708),
|
||||
("Valve", 0x25ED5F65),
|
||||
("Button", 0x3A42C455),
|
||||
("Number", 0xB900E22A),
|
||||
("Select", 0x556391B5),
|
||||
("Text", 0xB12BFA38),
|
||||
# Multi-word names (spaces become underscores, lowercase)
|
||||
("Living Room Light", 0xC6F81EC9),
|
||||
("Kitchen Switch", 0xC63C0F6E),
|
||||
("Temperature Sensor", 0x16AF55B6),
|
||||
("Garage Door Cover", 0x685E5281),
|
||||
("Bedroom Fan", 0x21AB1DED),
|
||||
("Front Door Lock", 0xB9BEF8E1),
|
||||
# Already snake_case names (should hash same as space-separated)
|
||||
("living_room_light", 0xC6F81EC9), # Same as "Living Room Light"
|
||||
("kitchen_switch", 0xC63C0F6E), # Same as "Kitchen Switch"
|
||||
# Names with numbers
|
||||
("Sensor 1", 0x99828E4B),
|
||||
("Relay 2", 0x6FFEF2FB),
|
||||
("Zone 10", 0xFD83AA95),
|
||||
# Names with special characters (become underscores)
|
||||
("AC Unit", 0x336C6886),
|
||||
("WiFi Signal", 0x2FA52175),
|
||||
("CO2 Level", 0x31049870),
|
||||
# Mixed case handling
|
||||
("mySwitch", 0x9AA10553),
|
||||
("MySwitch", 0x9AA10553), # Same as lowercase
|
||||
("MYSWITCH", 0x9AA10553), # Same as lowercase
|
||||
# =====================================================================
|
||||
# Edge cases
|
||||
# =====================================================================
|
||||
# Empty name (hashes to the FNV-1 offset basis since no chars processed)
|
||||
("", 0x811C9DC5),
|
||||
# Single character
|
||||
("a", 0x050C5D7E),
|
||||
("A", 0x050C5D7E), # Same after lowercase
|
||||
("1", 0x050C5D2E),
|
||||
("_", 0x050C5D40),
|
||||
# Names that differ only in case (should hash identically)
|
||||
("test", 0xBC2C0BE9),
|
||||
("Test", 0xBC2C0BE9),
|
||||
("TEST", 0xBC2C0BE9),
|
||||
# Names that differ only in spaces vs underscores (should hash identically)
|
||||
("foo bar", 0x3AE35AA1),
|
||||
("foo_bar", 0x3AE35AA1),
|
||||
("Foo Bar", 0x3AE35AA1),
|
||||
("FOO_BAR", 0x3AE35AA1),
|
||||
# Non-ASCII names (sanitized per code point, one underscore per character)
|
||||
("äöü", 0x10028B12),
|
||||
("温度", 0x3276CB9F),
|
||||
("Température", 0x965698F3),
|
||||
# =====================================================================
|
||||
# Real-world component entity names from ESPHome codebase
|
||||
# =====================================================================
|
||||
# From fan.cpp - FanRestoreState
|
||||
("Ceiling Fan", 0x640DEF00),
|
||||
# From climate.cpp - ClimateRestoreState
|
||||
("HVAC", 0xDD68438B),
|
||||
("Thermostat", 0x30A5B7C6),
|
||||
# From light/light_state.cpp
|
||||
("LED Strip", 0x2A068423),
|
||||
("Dimmable Light", 0xD70393F3),
|
||||
# From cover/cover.cpp
|
||||
("Garage Door", 0x53987A5D),
|
||||
("Window Blind", 0x851291A5),
|
||||
# From switch/switch.cpp
|
||||
("Relay", 0xD3A92FE4),
|
||||
("Power Switch", 0x5C4A47B3),
|
||||
# From number/automation.cpp
|
||||
("Brightness", 0xF46E252C),
|
||||
("Volume", 0x8FFEBE43),
|
||||
# From template datetime entities
|
||||
("Wake Time", 0xEE612B53),
|
||||
("Schedule Date", 0xF538C8DD),
|
||||
],
|
||||
)
|
||||
def test_entity_object_id_hash_stability(
|
||||
entity_name: str, expected_object_id_hash: int
|
||||
) -> None:
|
||||
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
|
||||
|
||||
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
|
||||
preferences stored under keys derived from this hash, and it is the entity
|
||||
key sent to API clients; changing it loses stored preferences and breaks
|
||||
entity state routing.
|
||||
"""
|
||||
actual = fnv1_hash_object_id(entity_name)
|
||||
assert actual == expected_object_id_hash, (
|
||||
f"Hash for '{entity_name}' changed from {expected_object_id_hash:#010x} to {actual:#010x}. "
|
||||
f"This will cause users to lose stored preferences!"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Legacy preference key computation formula
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def compute_legacy_preference_key(
|
||||
entity_name: str, version: int = 0, device_id: int = 0
|
||||
) -> int:
|
||||
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
|
||||
|
||||
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
|
||||
stores every entity preference under.
|
||||
"""
|
||||
object_id_hash = fnv1_hash_object_id(entity_name)
|
||||
preference_hash = object_id_hash ^ device_id
|
||||
key = preference_hash ^ version
|
||||
return key & 0xFFFFFFFF
|
||||
|
||||
|
||||
# Restore state version constants from ESPHome components
|
||||
# These MUST match the RESTORE_STATE_VERSION values in the C++ code
|
||||
FAN_RESTORE_STATE_VERSION = 0x71700ABA # From fan/fan.cpp
|
||||
CLIMATE_RESTORE_STATE_VERSION = 0x848EA6AD # From climate/climate.cpp
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_name", "version", "device_id", "expected_key"),
|
||||
[
|
||||
# No version, main device (key equals the plain object_id hash)
|
||||
("Test Sensor", 0, 0, 0x5D74FA46),
|
||||
("Light", 0, 0, 0x735CF023),
|
||||
# Restore state versions on the main device
|
||||
("Ceiling Fan", FAN_RESTORE_STATE_VERSION, 0, 0x157DE5BA),
|
||||
("HVAC", CLIMATE_RESTORE_STATE_VERSION, 0, 0x59E6E526),
|
||||
# Sub-devices: same entity name on different devices gets different keys
|
||||
("Light", 0, 1, 0x735CF022),
|
||||
("Fan", FAN_RESTORE_STATE_VERSION, 0xABCD, 0x37FFC6F7),
|
||||
],
|
||||
)
|
||||
def test_legacy_preference_key_computation(
|
||||
entity_name: str, version: int, device_id: int, expected_key: int
|
||||
) -> None:
|
||||
"""Verify legacy preference key computation matches expected values.
|
||||
|
||||
This test ensures the formula doesn't change, which would lose stored
|
||||
preferences on every platform.
|
||||
"""
|
||||
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
|
||||
|
||||
assert actual_key == expected_key, (
|
||||
f"Preference key for '{entity_name}' (version={version:#x}, device_id={device_id}) "
|
||||
f"changed from {expected_key:#010x} to {actual_key:#010x}. "
|
||||
f"This will cause users to lose stored preferences!"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: fnv1_hash_name produces stable entity keys (raw name, UTF-8 bytes)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_name", "expected_key"),
|
||||
[
|
||||
# ASCII names
|
||||
("Temperature Sensor", 0x801C3665),
|
||||
("LED Strip", 0xD5C7B082),
|
||||
("Garage Door", 0x2D70E086),
|
||||
("Relay", 0x565177C4),
|
||||
# Raw names are case and space sensitive, unlike the old object_id hash
|
||||
("temperature sensor", 0xF9F431E5),
|
||||
# Non-ASCII names hash their UTF-8 bytes and stay distinct
|
||||
("Датчик открытия", 0x001861C1),
|
||||
("温度", 0x8EDF61C9),
|
||||
("Température", 0x531A74AA),
|
||||
# Empty name hashes to the FNV-1 offset basis
|
||||
("", 0x811C9DC5),
|
||||
],
|
||||
)
|
||||
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
|
||||
"""Verify fnv1_hash_name produces stable raw-name hashes.
|
||||
|
||||
CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored
|
||||
preferences under keys derived from this hash; a future key migration must
|
||||
reconstruct those keys, and changing the algorithm would strand that data.
|
||||
Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores.
|
||||
"""
|
||||
actual = fnv1_hash_name(entity_name)
|
||||
assert actual == expected_key, (
|
||||
f"Entity key for '{entity_name}' changed from {expected_key:#010x} to {actual:#010x}. "
|
||||
f"This breaks state routing and stored preferences!"
|
||||
)
|
||||
|
||||
|
||||
def test_fnv1_hash_name_matches_utf8_byte_hash() -> None:
|
||||
"""Verify fnv1_hash_name hashes the UTF-8 encoded bytes of the name."""
|
||||
name = "Température 温度"
|
||||
hash_value = FNV1_OFFSET_BASIS
|
||||
for byte in name.encode("utf-8"):
|
||||
hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF
|
||||
hash_value ^= byte
|
||||
assert fnv1_hash_name(name) == hash_value
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user