Merge remote-tracking branch 'origin/dev' into store-yaml-firmware

# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
#	tests/integration/conftest.py
#	tests/unit_tests/test_yaml_util.py
This commit is contained in:
J. Nick Koston
2026-09-14 11:07:43 -05:00
2889 changed files with 129953 additions and 28736 deletions
@@ -9,7 +9,7 @@ from esphome.analyze_memory.toolchain import (
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.build_helpers.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
@@ -22,7 +22,7 @@ def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
def _touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
path.write_text("", encoding="utf-8")
return path
@@ -143,8 +143,8 @@ def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
def test_native_idedata_resolves_toolchain_tools() -> None:
"""The binutils paths are derived from the native ESP-IDF cc_path.
Without cc_path, IDEData.objdump_path raises KeyError and the memory
analysis silently degrades to no component or symbol detail.
Without cc_path, IDEData.objdump_path raises EsphomeError and the
memory analysis silently degrades to no component or symbol detail.
"""
idedata = IDEData(
{
@@ -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()
+223 -18
View File
@@ -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,168 +0,0 @@
"""Tests for esphome.components.api.client."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
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_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
"""
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 the toolchain; 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
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,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,22 @@
"""Tests for the per-board linker-script rule."""
from esphome.components.esp8266 import _choose_ld_script
from esphome.components.esp8266.boards import BOARDS, board_ld_script
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:
"""Default boards get the size layout, overriding boards keep theirs."""
assert _choose_ld_script("nodemcuv2") == "eagle.flash.4m.ld"
assert _choose_ld_script("d1_wroom_02") == "eagle.flash.2m64.ld"
@@ -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,23 @@
"""Tests for the Arduino framework version floor."""
import pytest
from esphome.components.esp8266 import _arduino_check_versions
import esphome.config_validation as cv
from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION
def test_versions_before_3_are_rejected() -> None:
with pytest.raises(cv.Invalid, match="no longer supported") as excinfo:
_arduino_check_versions({CONF_VERSION: "2.7.4"})
assert excinfo.value.path == [CONF_VERSION]
def test_supported_versions_pass() -> None:
value = _arduino_check_versions({CONF_VERSION: "3.0.2"})
assert value[CONF_VERSION] == "3.0.2"
assert "espressif8266@3.2.0" in value[CONF_PLATFORM_VERSION]
value = _arduino_check_versions({CONF_VERSION: "recommended"})
assert value[CONF_VERSION] == "3.1.2"
assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION]
@@ -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
@@ -16,6 +16,7 @@ from esphome.const import (
CONF_TYPE,
CONF_URL,
)
from esphome.external_files import RemoteFile
@pytest.fixture
@@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models(
assert mock_download_content_many.call_count == 2
manifest_items = list(mock_download_content_many.call_args_list[0].args[0])
assert manifest_items == [
(f"https://example.com/models/{name}.json", paths[name] / "manifest.json")
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 == [
(f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite")
RemoteFile(
f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite"
)
for name in names
]
@@ -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
@@ -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,48 @@
"""Tests for the espnow component's final validation."""
import pytest
from esphome.components.esp32.const import (
VARIANT_ESP32C3,
VARIANT_ESP32H2,
VARIANT_ESP32P4,
)
from esphome.components.espnow import _validate_variant
import esphome.config_validation as cv
import esphome.final_validate as fv
from esphome.types import ConfigType
def _run(
monkeypatch, variant: str, full_config: dict, config: ConfigType
) -> ConfigType:
monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant)
token = fv.full_config.set(full_config)
try:
return _validate_variant(config)
finally:
fv.full_config.reset(token)
def test_variant_with_native_wifi_passes(monkeypatch) -> None:
"""A variant with a native Wi-Fi PHY needs no shim; config passes through."""
config = {"id": "espnow"}
assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config
def test_radioless_non_p4_variant_rejected(monkeypatch) -> None:
"""Radio-less variants without any ESP-NOW path are rejected outright."""
with pytest.raises(cv.Invalid, match="not supported"):
_run(monkeypatch, VARIANT_ESP32H2, {}, {})
def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None:
"""The P4 needs the esp32_hosted shim to supply the esp_now_* symbols."""
with pytest.raises(cv.Invalid, match="esp32_hosted"):
_run(monkeypatch, VARIANT_ESP32P4, {}, {})
def test_p4_with_esp32_hosted_passes(monkeypatch) -> None:
"""The P4 with esp32_hosted present validates; config passes through."""
config = {"id": "espnow"}
assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config
+36 -2
View File
@@ -2,7 +2,8 @@
import pytest
from esphome.components.libretiny import _detect_variant
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,
@@ -11,7 +12,7 @@ from esphome.components.libretiny.const import (
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
from esphome.core import CORE, KEY_CORE
@pytest.fixture
@@ -50,3 +51,36 @@ def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> No
"""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
+97 -10
View File
@@ -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:
@@ -0,0 +1,37 @@
"""Tests for the udp component configuration schema."""
from __future__ import annotations
import pytest
from esphome.components import udp
from esphome.components.packet_transport import (
CONF_BINARY_SENSORS,
CONF_ENCRYPTION,
CONF_PING_PONG_ENABLE,
CONF_PROVIDERS,
CONF_ROLLING_CODE_ENABLE,
CONF_SENSORS,
)
import esphome.config_validation as cv
@pytest.mark.parametrize(
"option",
[
CONF_PROVIDERS,
CONF_ENCRYPTION,
CONF_PING_PONG_ENABLE,
CONF_ROLLING_CODE_ENABLE,
CONF_SENSORS,
CONF_BINARY_SENSORS,
],
)
def test_relocated_option_rejected(option: str) -> None:
"""Options that moved to packet_transport raise a pointing error."""
with pytest.raises(cv.Invalid) as exc_info:
udp.CONFIG_SCHEMA({option: True})
assert (
f"The '{option}' option should now be configured in the 'packet_transport' component"
in str(exc_info.value)
)
+52 -1
View File
@@ -9,7 +9,8 @@ not be part of a unit test suite.
"""
from collections.abc import Generator
from collections.abc import Callable, Generator
import os
from pathlib import Path
import sys
from unittest.mock import Mock, patch
@@ -51,6 +52,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."""
@@ -134,3 +148,40 @@ def mock_get_component() -> Generator[Mock, None, None]:
"""Mock get_component for config module."""
with patch("esphome.config.get_component") as mock:
yield mock
@pytest.fixture
def held_lock() -> Callable[..., Callable[..., None]]:
"""Factory for a ``FileLock.acquire`` fake held by another downloader.
Each poll writes the next chunk to ``part`` (or runs it, for a callable)
and raises ``Timeout``; when the chunks run out the part is removed,
``land()`` runs, and the acquire succeeds (also for any later job, so
``land`` must be idempotent).
"""
from filelock import Timeout
def make(
part: Path,
chunks: list[bytes | Callable[[], None]],
land: Callable[[], None],
) -> Callable[..., None]:
polls = iter(chunks)
def acquire(*args, **kwargs) -> None:
try:
chunk = next(polls)
except StopIteration:
part.unlink(missing_ok=True)
land()
return
if callable(chunk):
chunk()
else:
part.parent.mkdir(parents=True, exist_ok=True)
part.write_bytes(chunk)
raise Timeout("held")
return acquire
return make
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+86 -28
View 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"})
+44 -12
View File
@@ -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,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()
+486
View File
@@ -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,165 @@
"""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"
# 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))
# Cores before 3.x cannot build ESPHome (C++20) and are rejected
with pytest.raises(EsphomeError, match="requires core 3"):
framework.framework_package_version(cv.Version(2, 7, 4))
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(3, 1, 2)) == "~3.30102.0"
# Pre-3 cores are rejected with the version line anchored
with pytest.raises(cv.Invalid, match="requires core 3"):
fmt(cv.Version(2, 7, 4))
# 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
+316
View File
@@ -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
+152 -23
View File
@@ -23,14 +23,15 @@ from esphome.bundle import (
_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
@@ -98,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
# ---------------------------------------------------------------------------
@@ -1248,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"
@@ -1262,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:
@@ -1594,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)
+588 -81
View File
@@ -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,
@@ -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.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,7 +673,7 @@ 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": {}}
@@ -303,6 +683,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
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
@@ -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
+40 -1
View File
@@ -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"]
+122 -2
View File
@@ -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:`.
+355
View File
@@ -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
+416 -2
View File
@@ -1,6 +1,10 @@
import importlib
import io
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
@@ -17,6 +21,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S2,
VARIANT_ESP32S3,
)
from esphome.components.substitutions import do_substitution_pass
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DAY,
@@ -46,6 +51,7 @@ from esphome.const import (
TYPE_GIT,
TYPE_LOCAL,
Framework,
Toolchain,
)
from esphome.core import (
CORE,
@@ -61,7 +67,13 @@ from esphome.core import (
)
from esphome.schema_extractors import SCHEMA_EXTRACT
from esphome.util import Registry
from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base
from esphome.yaml_util import (
ESPHomeDataBase,
SensitiveStr,
load_yaml,
make_data_base,
parse_yaml,
)
def test_check_not_templatable__invalid():
@@ -930,7 +942,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:
@@ -1390,6 +1402,35 @@ def test_entity_metadata_visibility_hints() -> None:
assert web["web_server"].visibility is advanced
def test_with_visibility_remarks_keys() -> None:
"""``with_visibility`` re-marks the named keys, preserving each field's
default and validator, without touching the other keys or the input schema.
"""
base = cv.Schema(
{
cv.Optional("a", default=7): cv.int_,
cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string,
}
)
promoted = cv.with_visibility(base, cv.Visibility.UI, "a")
pm = {str(k): k for k in promoted.schema}
assert pm["a"].visibility is cv.Visibility.UI # re-marked
assert pm["a"].default() == 7 # default preserved
assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched
assert promoted({}) == {"a": 7} # validator/default still applied
# The input schema is left untouched (no shared-marker mutation).
assert {str(k): k for k in base.schema}["a"].visibility is None
def test_with_visibility_unknown_key_raises() -> None:
"""A key not present in the schema is a typo — fail at build time."""
base = cv.Schema({cv.Optional("a"): cv.int_})
with pytest.raises(ValueError, match="not in schema"):
cv.with_visibility(base, cv.Visibility.UI, "nope")
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)
@@ -2427,6 +2468,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
@@ -2465,6 +2558,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
# ---------------------------------------------------------------------------
@@ -2495,6 +2602,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
# ---------------------------------------------------------------------------
@@ -2859,11 +3012,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
# ---------------------------------------------------------------------------
@@ -2915,12 +3120,178 @@ 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 _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]:
"""Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value."""
package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor"
(package_dir / "assets").mkdir(parents=True)
(package_dir / "assets" / "ui.js").write_text("js\n")
(package_dir / "device.yaml").write_text(f"path: {path}\n")
return package_dir, load_yaml(package_dir / "device.yaml")["path"]
def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None:
"""A package's own asset path resolves against the package file when the config dir lacks it."""
package_dir, value = _package_value(setup_core)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__resolves_a_substituted_path_against_the_use_site(
setup_core: Path,
) -> None:
package_dir, _ = _package_value(setup_core)
(package_dir / "device.yaml").write_text(
"substitutions:\n ui: assets/ui.js\npath: ${ui}\n"
)
config = do_substitution_pass(load_yaml(package_dir / "device.yaml"))
assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js"
def test_file__result_is_absolute_for_a_relative_document(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A document loaded by a cwd-relative path still yields an absolute result."""
package_dir, _ = _package_value(setup_core)
monkeypatch.chdir(setup_core)
value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"]
result = cv.file_(value)
assert result.is_absolute()
assert result == package_dir / "assets" / "ui.js"
def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core)
(setup_core / "assets" / "ui.js").mkdir(parents=True)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__miss_names_the_declaring_document(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets/other.js")
with pytest.raises(Invalid, match="Could not find file") as excinfo:
cv.file_(value)
assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value)
def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint(
setup_core: Path,
) -> None:
(setup_core / "sub").mkdir()
(setup_core / "device.yaml").write_text("path: assets/other.js\n")
value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"]
with pytest.raises(Invalid) as excinfo:
cv.file_(value)
assert "Also looked" not in str(excinfo.value)
def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets")
with pytest.raises(Invalid, match="is not a file") as excinfo:
cv.file_(value)
assert str(package_dir / "assets") in str(excinfo.value)
def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None:
_, value = _package_value(setup_core)
(setup_core / "assets").mkdir()
(setup_core / "assets" / "ui.js").write_text("local\n")
assert cv.file_(value) == setup_core / "assets" / "ui.js"
def test_file__declared_in_an_in_memory_document_is_not_resolved(
setup_core: Path,
) -> None:
"""A value whose source document isn't on disk falls through to the config-dir error."""
value = parse_yaml(Path("<unicode string>"), io.StringIO("path: assets/ui.js\n"))[
"path"
]
with pytest.raises(Invalid, match="Could not find file"):
cv.file_(value)
def test_directory_resolves_relative_to_the_declaring_document(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core, "assets")
assert cv.directory(value) == package_dir / "assets"
def test_file__missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("partitions.csv")
@@ -2989,3 +3360,46 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
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))
+78 -1
View File
@@ -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"}
+45
View File
@@ -1,5 +1,7 @@
"""Tests for the coroutine module."""
import contextvars
import pytest
from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority
@@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None:
# Check execution order
assert execution_order == ["core", "custom", "diagnostics"]
def test_context_isolated_between_interleaved_tasks() -> None:
"""Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
seen: dict[str, str] = {}
def task_a():
my_var.set("a")
yield # suspend so task_b can run before task_a resumes
seen["a"] = my_var.get()
def task_b():
my_var.set("b")
yield
seen["b"] = my_var.get()
loop = FakeEventLoop()
loop.add_job(task_a)
loop.add_job(task_b)
loop.flush_tasks()
assert seen == {"a": "a", "b": "b"}
def test_context_inherits_ambient_value_at_schedule_time() -> None:
"""Test that a job sees whatever contextvar value was set before it was scheduled."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
token = my_var.set("ambient")
seen: dict[str, str] = {}
def task():
seen["value"] = my_var.get()
yield
try:
loop = FakeEventLoop()
loop.add_job(task)
loop.flush_tasks()
finally:
my_var.reset(token)
assert seen == {"value": "ambient"}
+79
View File
@@ -85,6 +85,15 @@ class TestCallExpression:
assert actual == 'my_function<int32_t, float>(1, "2", false)'
class TestStaticCastExpression:
def test_str(self):
target = cg.StaticCastExpression(ct.bool_, 42)
actual = str(target)
assert actual == "static_cast<bool>(42)"
class TestStructInitializer:
def test_str(self):
target = cg.StructInitializer(
@@ -229,6 +238,76 @@ class TestLambdaExpression:
)
class TestCallLambda:
"""Tests for the call_lambda() function."""
def test_call_lambda__return_expression_casts_to_return_type(self):
"""A lambda body that is just a return statement reduces to the
expression, cast to the lambda's return type."""
lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.StaticCastExpression)
assert str(result) == "static_cast<bool>(foo + 1)"
def test_call_lambda__return_expression_with_class_return_type_no_cast(self):
"""A class return type is not cast, since static_cast doesn't apply
to arbitrary class types."""
mock_class = cg.MockObjClass("foo::Bar", parents=())
lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.RawExpression)
assert str(result) == "get_bar()"
def test_call_lambda__no_return_with_parameters_calls_with_names(self):
"""A multi-statement lambda with parameters is called with the
parameter names as arguments."""
lamb = cg.LambdaExpression(
("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_
)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == (
"[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)"
)
def test_call_lambda__no_return_type_raises(self):
"""Calling a lambda with no declared return type is a developer
error: call_lambda is only for value-returning lambdas."""
lamb = cg.LambdaExpression(("do_something();",), (), "=")
with pytest.raises(AssertionError):
cg.call_lambda(lamb)
def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement(
self,
):
"""A body that merely starts with the substring "return" (e.g. a call
to a function named returnValue()) must not be mistaken for a return
statement -- the match requires a word boundary after "return"."""
lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == "[=]() -> bool {\n returnValue();\n}()"
def test_call_lambda__no_return_no_parameters_calls_with_no_args(self):
"""A multi-statement lambda without parameters is called with no
arguments."""
lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == "[]() -> bool {\n do_something();\n}()"
class TestLiterals:
@pytest.mark.parametrize(
"target, expected",
+76
View File
@@ -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,78 @@ 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_keyed_emits_largest_count() -> None:
"""Keyed requests size storage every key declares at the same capacity:
the define is the busiest key's count, not the total over all keys."""
request = ch.slot_counter("TEST_SLOT_COUNT_KEYED")
request("rx_a")
request("rx_a")
request("rx_a")
request("rx_b")
assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_KEYED") == "3"
def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None:
"""A keyed and an unkeyed request for one define cannot be sized together."""
request = ch.slot_counter("TEST_SLOT_COUNT_MIXED")
request("rx_a")
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"):
request()
unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2")
unkeyed()
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"):
unkeyed("rx_a")
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()
+55
View File
@@ -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"))
+52
View File
@@ -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)
+276 -178
View File
@@ -1,12 +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,
@@ -16,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,
)
@@ -155,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()
@@ -169,30 +228,57 @@ 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
@@ -208,6 +294,38 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
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
@@ -256,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"
@@ -286,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"
@@ -427,7 +435,7 @@ empty=
"""
)
result = _parse_library_properties(f)
result = parse_library_properties(f)
assert result["name"] == "Test"
assert result["version"] == "1.0"
@@ -435,70 +443,66 @@ 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, is_git, locator = _node_key(
"https://github.com/pstolarz/OneWireNg", None, None
)
key, kind, locator = _node_key("https://github.com/pstolarz/OneWireNg", None, None)
assert key == "pstolarz/OneWireNg"
assert is_git is True
assert kind == "git"
assert locator == ("https://github.com/pstolarz/OneWireNg", None)
def test_node_key_url_in_name_with_ref():
key, is_git, locator = _node_key(
"https://github.com/foo/bar.git#v1.2.3", None, None
)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("https://github.com/foo/bar.git#v1.2.3", None, None)
assert (key, kind, locator) == (
"foo/bar",
True,
"git",
("https://github.com/foo/bar.git", "v1.2.3"),
)
def test_node_key_url_in_name_git_plus_prefix():
key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("git+https://github.com/foo/bar", None, None)
assert (key, kind, locator) == (
"foo/bar",
True,
"git",
("https://github.com/foo/bar", None),
)
def test_node_key_git_plus_prefix_in_repository():
_key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar")
assert (is_git, locator) == (True, ("https://github.com/foo/bar", None))
_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, is_git, locator = _node_key(
key, kind, locator = _node_key(
"OneWireNg=https://github.com/pstolarz/OneWireNg", None, None
)
assert (key, is_git, locator) == (
assert (key, kind, locator) == (
"pstolarz/OneWireNg",
True,
"git",
("https://github.com/pstolarz/OneWireNg", None),
)
@@ -506,14 +510,70 @@ def test_node_key_custom_name_equals_url_is_git():
def test_node_key_url_in_name_with_query_containing_equals():
# A bare URL whose query string contains ``=`` must not be split by the
# CustomName=URL handling.
key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("https://host/x/y.git?ref=main", None, None)
assert (key, kind, locator) == (
"x/y",
True,
"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
@@ -523,44 +583,44 @@ def test_node_key_malformed_url_in_name_raises(name: str) -> None:
def test_node_key_name_with_equals_but_no_url_is_registry():
key, is_git, locator = _node_key("FOO=BAR", "1.0", None)
assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR"))
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, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None)
assert (key, is_git) == ("bar", False)
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 == [
@@ -600,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")
@@ -609,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"
@@ -659,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
@@ -718,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
@@ -774,6 +836,7 @@ def test_generate_idf_components_handles_dependency_cycle(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -831,6 +894,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -867,6 +931,7 @@ def test_generate_idf_components_missing_manifest_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -911,6 +976,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -944,6 +1010,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -980,6 +1047,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1055,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
+661 -53
View File
@@ -2,6 +2,7 @@
# pylint: disable=protected-access
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import importlib.util
import io
@@ -14,12 +15,15 @@ import subprocess
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
_ccache_env,
_check_esphome_idf_framework_install,
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
@@ -29,9 +33,11 @@ from esphome.espidf.framework import (
_get_python_env_path,
_get_python_version,
_parse_git_source,
_patch_tools_json_demote_openocd,
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
_write_idf_version_txt,
_write_stamp,
@@ -137,10 +143,17 @@ def test_parse_git_source_rejected(source: str) -> None:
assert _parse_git_source(source) is None
def _make_idf_tree(framework_path: Path) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for.
``gitmodules=False`` simulates a fork that vendors components in-tree
instead of declaring submodules; update_submodules skips the git call
when that file is missing.
"""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
if gitmodules:
(framework_path / ".gitmodules").write_text("# stub\n")
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
@@ -166,6 +179,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
assert not any(c[1] == "fetch" for c in calls)
assert not any(c[1] == "reset" for c in calls)
# The clone must retry transient network failures and clean up a
# partial destination between attempts
clone_kwargs = run_git_command_mock.call_args_list[0].kwargs
assert clone_kwargs["network"] is True
assert clone_kwargs["retry_cleanup"] == framework_path
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
@@ -193,6 +211,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
]
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
# Clone and fetch talk to the network and must carry the retry flag;
# the local reset must not
kwargs = [c.kwargs for c in run_git_command_mock.call_args_list]
assert kwargs[0]["network"] is True
assert kwargs[0]["retry_cleanup"] == framework_path
assert kwargs[1]["network"] is True
assert "network" not in kwargs[2]
def test_clone_idf_with_submodules_raises_when_tree_missing(
@@ -214,6 +239,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing(
)
def test_clone_idf_accepts_flattened_fork_without_gitmodules(
tmp_path: Path,
) -> None:
"""A fork that vendors components in-tree instead of as submodules is valid.
No .gitmodules means the submodule step is skipped entirely.
"""
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path, gitmodules=False)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path,
"https://github.com/example/flattened-esp-idf.git",
None,
)
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
assert not any(c[1] == "submodule" for c in calls)
# ---------------------------------------------------------------------------
# Helpers for _tar_extract_all hard-link prefix-stripping tests
# ---------------------------------------------------------------------------
@@ -324,10 +371,9 @@ def _fake_download_from_mirrors(
) -> str:
"""Stand-in for download_from_mirrors that creates path targets, since
the framework code opens the downloaded tarball afterwards."""
if isinstance(target, (str, os.PathLike)):
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return "https://example.com/idf.tar.xz"
@@ -337,13 +383,15 @@ def espidf_mocks(setup_core: Path):
# archive_extract_all is mocked, so pre-create the framework dir that the
# extracted-marker touch writes into.
_get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True)
# One mock covers the tarball (via framework_helpers.download_and_extract)
# and the constraints file (espidf-bound download_from_mirrors), so call
# counts and ordering assertions span the two.
download = MagicMock(side_effect=_fake_download_from_mirrors)
with (
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
patch(
"esphome.espidf.framework.download_from_mirrors",
side_effect=_fake_download_from_mirrors,
) as download,
patch("esphome.espidf.framework.archive_extract_all") as extract,
patch("esphome.framework_helpers.download_from_mirrors", download),
patch("esphome.espidf.framework.download_from_mirrors", download),
patch("esphome.framework_helpers.archive_extract_all") as extract,
patch("esphome.espidf.framework.create_venv") as venv,
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch(
@@ -352,10 +400,11 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._clone_idf_with_submodules") as clone,
patch("esphome.espidf.framework._write_idf_version_txt"),
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
patch("esphome.espidf.framework._patch_tools_json_demote_openocd"),
patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"),
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
patch("esphome.espidf.framework._write_stamp"),
patch("esphome.espidf.framework._check_stamp", return_value=True),
patch("esphome.espidf.framework._stamp_covers", return_value=True),
patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION),
patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -471,6 +520,33 @@ def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) ->
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
def test_python_deps_use_uv_when_available(
espidf_mocks: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The python env installs go through uv when on the PATH, pip otherwise."""
monkeypatch.delenv("UV_HTTP_RETRIES", raising=False)
with patch(
"esphome.espidf.framework.shutil.which",
# Keyed on the name: the same which() also probes the default tools
side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None,
):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call, feature_call = espidf_mocks.run_ok.call_args_list[1:3]
upgrade_cmd, feature_cmd = upgrade_call.args[0], feature_call.args[0]
assert upgrade_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert "--python" in upgrade_cmd
assert feature_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "10"
espidf_mocks.run_ok.reset_mock()
monkeypatch.setenv("UV_HTTP_RETRIES", "3") # an explicit user value wins
with patch("esphome.espidf.framework.shutil.which", return_value=None):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call = espidf_mocks.run_ok.call_args_list[1]
assert upgrade_call.args[0][1:4] == ["-m", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "3"
def _mark_installed() -> None:
"""Create the extracted marker and python-env interpreter so the install
check takes the already-installed path rather than force-installing."""
@@ -485,13 +561,17 @@ def _mark_installed() -> None:
def test_check_esp_idf_install_stamp_mismatch_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A stamp mismatch reinstalls tools (marker present, so no re-extract)."""
"""A stamp mismatch reinstalls tools (marker present, so no re-extract).
The python env is left alone: it depends on the framework version and
features, not on which toolchains are installed.
"""
_mark_installed()
with patch("esphome.espidf.framework._check_stamp", return_value=False):
with patch("esphome.espidf.framework._stamp_covers", return_value=False):
check_esp_idf_install(_IDF_VERSION)
espidf_mocks.extract.assert_not_called() # marker present -> no re-extract
espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_check_command_failure_reinstalls(
@@ -504,7 +584,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls(
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
espidf_mocks.venv.assert_called_once()
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_unknown_python_version_reinstalls(
@@ -524,8 +604,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
) -> None:
"""Framework stamp matches but the python-env stamp does not -> venv rebuilt."""
# _check_stamp passes for the framework (no python_version key) and fails
# for the python env (carries python_version), so only the venv rebuilds.
# _check_stamp only guards the python env now (the framework uses
# _stamp_covers, patched True by the fixture); failing it rebuilds the venv.
def stamp_ok(_stamp_file, info: dict) -> bool:
return "python_version" not in info
@@ -537,6 +617,146 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
espidf_mocks.venv.assert_called_once()
def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict:
return {
"schema_version": STAMP_SCHEMA_VERSION,
"targets": targets,
"tools": tools or ["required"],
}
@pytest.mark.parametrize(
("stored", "targets", "expected"),
[
# a stored "all" covers any target
(_requested_stamp(["all"]), ["esp32"], True),
# exact match and superset both cover
(_requested_stamp(["esp32"]), ["esp32"], True),
(_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True),
# a new target is not covered
(_requested_stamp(["esp32"]), ["esp32c3"], False),
# tools and schema_version must match exactly
(_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False),
(_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False),
# an unknown extra field participates in invalidation by default
(_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False),
# missing/corrupt stamps never cover
(None, ["esp32"], False),
(
{"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]},
["esp32"],
False,
),
],
)
def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None:
assert _stamp_covers(stored, _requested_stamp(targets)) is expected
@contextmanager
def _framework_install_patches():
"""Patches for calling _check_esphome_idf_framework_install directly with
real stamp files (unlike espidf_mocks, which stubs the stamp layer)."""
with (
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.rmdir"),
):
yield run_ok
def _extracted_framework_with_stamp(stamp: dict) -> Path:
framework_path = _get_framework_path(_IDF_VERSION)
framework_path.mkdir(parents=True, exist_ok=True)
(framework_path / ".esphome_extracted").touch()
_write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp)
return framework_path
def test_framework_install_target_subset_skips_install() -> None:
"""A stamp holding a superset of the requested targets skips the installer."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32"], ["required"]
)
run_ok.assert_not_called()
assert fresh_extract is False
# the stamp is untouched
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_new_target_installs_and_merges_stamp() -> None:
"""A new target runs the installer for just that target and the stamp
records the union of everything installed so far."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32c3"], ["required"]
)
assert fresh_extract is False
assert "--targets=esp32c3" in run_ok.call_args[0][0]
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32", "esp32c3"]
def test_check_esp_idf_install_env_targets_override_wins(
espidf_mocks: SimpleNamespace,
) -> None:
"""An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets."""
with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True):
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=all" in install_cmd
def test_check_esp_idf_install_uses_requested_targets(
espidf_mocks: SimpleNamespace,
) -> None:
"""Without the env override, the caller's per-variant targets are installed."""
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=esp32" in install_cmd
def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None:
"""Requesting "all" over a per-variant stamp merges and collapses to
["all"], not ["all", "esp32"], so the stamp shape stays canonical."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_tools_change_resets_stamp_targets() -> None:
"""A reinstall triggered by a tools change must not carry the old stamp's
targets forward: the installer only ran for this build's targets, so a
merged stamp would let other variants skip the reinstall they need."""
framework_path = _extracted_framework_with_stamp(
_requested_stamp(["all"], tools=["cmake", "required"])
)
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32"]
assert stamp["tools"] == ["required"]
@pytest.mark.parametrize(
("lib", "expect_hint"),
[
@@ -696,24 +916,138 @@ _PREFETCH_JSON = json.dumps(
)
def test_prefetch_leaves_unverifiable_entries_to_the_installer(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An entry missing sha256 or size must not download unverified; the
installer handles it and fails loudly on a bad archive."""
entries = json.loads(_PREFETCH_JSON)
del entries[0]["sha256"]
del entries[1]["size"]
entries.append(
{
"name": "gcc@14.2.0",
"url": "https://example.com/gcc.tar.gz",
"size": 67,
"sha256": "ef" * 32,
"dest": "gcc.tar.gz",
}
)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert [call[0][0] for call in download.call_args_list] == [
"https://example.com/gcc.tar.gz"
]
assert download.call_args[1]["sha256"] == "ef" * 32
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 67)
assert "cmake@3.30.2 has no sha256/size" in caplog.text
assert "ninja@1.12.1 has no sha256/size" in caplog.text
def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
entries = json.loads(_PREFETCH_JSON)
for entry in entries:
del entry["sha256"]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
download.assert_not_called()
def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
"""Two entries resolving to one dest would interleave writes into the
same .part file; only the first downloads."""
entries = json.loads(_PREFETCH_JSON)
dup = dict(entries[0]) | {"name": "cmake-alias@3.30.2"}
entries.append(dup)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dests = [call[0][1].name for call in download.call_args_list]
assert dests.count("cmake-3.30.2.tar.gz") == 1
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
# Materialize the lazy mock before threads race its first creation
tracker = progress_cls.return_value.tracker.return_value
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist"
assert download.call_count == 2
assert download.call_args_list[0][0] == (
"https://example.com/cmake.tar.gz",
dist / "cmake-3.30.2.tar.gz",
)
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
# Archives download concurrently, so the call order is not fixed.
calls = {call[0]: call[1] for call in download.call_args_list}
assert set(calls) == {
("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
("https://example.com/ninja.zip", dist / "ninja.zip"),
}
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
assert kwargs["sha256"] == "ab" * 32
assert kwargs["size"] == 123
# every archive reports into the one combined progress bar via the
# cancellation-checked wrapper; verify it delegates to the tracker
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45)
before = tracker.call_count
for kw in calls.values():
kw["progress"](7)
assert tracker.call_count == before + len(calls)
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"""More than one archive fans out over a bounded thread pool."""
entries = [
{
"name": f"tool{i}@1",
"url": f"https://example.com/tool{i}.tar.gz",
"size": 10,
"sha256": "ab" * 32,
"dest": f"tool{i}.tar.gz",
}
for i in range(6)
]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.assert_called_once_with(max_workers=4)
assert download.call_count == 6
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
@@ -725,7 +1059,7 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
@@ -758,7 +1092,7 @@ def test_prefetch_failures_never_raise(
with (
patch("esphome.espidf.framework.run_command", return_value=run_result),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=download_error,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -768,19 +1102,45 @@ def test_prefetch_failures_never_raise(
assert expected_log in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
def test_prefetch_total_failure_logs_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
"""Every archive failing is a systematic fault (proxy, bad kwarg), not
a flaky mirror; it must be distinguishable at ERROR because the resume
workaround is off for the whole install."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None],
"esphome.framework_helpers.download_with_resume",
side_effect=OSError("proxy refuses everything"),
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert "Every ESP-IDF tool prefetch failed" in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
if "cmake" in url:
raise OSError("network down")
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.framework_helpers.download_with_resume",
side_effect=_fail_cmake_download,
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
@@ -788,6 +1148,29 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
assert download.call_count == 2
assert "Could not prefetch cmake@3.30.2" in caplog.text
# One flaky archive is routine, never the systematic-fault ERROR
assert "Every ESP-IDF tool prefetch failed" not in caplog.text
def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> None:
"""The batch bar is closed out after the pool, and the pool is shut down
with cancel_futures so Ctrl-C does not drain every queued archive."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.framework_helpers.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
):
pool = MagicMock(wraps=ThreadPoolExecutor(max_workers=2))
pool_cls.return_value = pool
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
progress_cls.return_value.done.assert_called_once_with()
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
@@ -952,28 +1335,97 @@ def test_get_tool_downloads_inprocess_explicit_tool_specs(
# ---------------------------------------------------------------------------
# _patch_tools_json_demote_openocd (openocd-esp32 made optional)
# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional)
# ---------------------------------------------------------------------------
def test_demote_openocd_patches_install_type(tmp_path: Path) -> None:
def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "always"},
{"name": "cmake", "install": "always"},
{"name": "xtensa-esp-elf-gdb", "install": "always"},
{"name": "riscv32-esp-elf-gdb", "install": "always"},
{"name": "esp32ulp-elf", "install": "always"},
{"name": "xtensa-esp-elf", "install": "always"},
{"name": "esp-rom-elfs", "install": "always"},
]
},
)
_patch_tools_json_demote_openocd(tmp_path)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
install_types = {t["name"]: t["install"] for t in data["tools"]}
assert install_types == {
"openocd-esp32": "on_request",
"xtensa-esp-elf-gdb": "on_request",
"riscv32-esp-elf-gdb": "on_request",
"esp32ulp-elf": "on_request",
# the compiler toolchain and ROM ELFs stay required
"xtensa-esp-elf": "always",
"esp-rom-elfs": "always",
}
def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None:
"""riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome
never builds) but keeps its RISC-V targets; other tools are untouched."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"],
},
{
"name": "xtensa-esp-elf",
"install": "always",
"supported_targets": ["esp32", "esp32s2", "esp32s3"],
},
]
},
)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf")
assert riscv["supported_targets"] == ["esp32c3", "esp32p4"]
assert riscv["install"] == "always"
assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"]
def test_demote_unused_tools_bad_supported_targets_type_still_demotes(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-list supported_targets on riscv32-esp-elf must not abort the
other demotions; the targets patch is best-effort and logs the skip so a
silently resumed riscv download is diagnosable."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": None,
},
{"name": "openocd-esp32", "install": "always"},
]
},
)
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32")
cmake = next(t for t in data["tools"] if t["name"] == "cmake")
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
assert openocd["install"] == "on_request"
# other tools are left untouched
assert cmake["install"] == "always"
assert riscv["supported_targets"] is None
assert "Unexpected supported_targets" in caplog.text
def test_patch_tools_json_unexpected_structure_warns_and_skips(
@@ -985,16 +1437,29 @@ def test_patch_tools_json_unexpected_structure_warns_and_skips(
tools_json = tools_dir / "tools.json"
tools_json.write_text('["not", "a", "dict"]', encoding="utf-8")
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip
_patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip
assert tools_json.read_text(encoding="utf-8") == before
def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None:
def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]}
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "on_request"},
{"name": "xtensa-esp-elf-gdb", "install": "on_request"},
{"name": "riscv32-esp-elf-gdb", "install": "on_request"},
{"name": "esp32ulp-elf", "install": "on_request"},
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32c3", "esp32p4"],
},
]
},
)
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_openocd(tmp_path)
_patch_tools_json_demote_unused_tools(tmp_path)
assert tools_json.read_text(encoding="utf-8") == before
@@ -1122,13 +1587,14 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No
def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
return (
patch("esphome.espidf.framework.shutil.which", return_value=which),
patch("esphome.espidf.framework.resolve_ccache_path", return_value=which),
patch(
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
patch(
"esphome.espidf.framework.CORE",
"esphome.core.CORE",
SimpleNamespace(build_path=build_path),
),
)
@@ -1149,7 +1615,8 @@ def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
# build_path is None here too: a disabled cache must not require it.
p1, p2, p3 = _ccache_patches(tmp_path, None, None)
with patch.dict("os.environ", {}, clear=True), p1, p2, p3:
assert _ccache_env() == {}
# Canonical off, so an inherited/unparsable value cannot enable it
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
@@ -1157,18 +1624,111 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
# short-circuits before build_path is needed.
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None)
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3:
assert _ccache_env() == {}
# The canonical off spelling is exported: the raw value is inherited
# by idf.py, where a spelling like "disable" would read as truthy
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None:
# Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's
# already in the environment, so it isn't re-emitted, but the rest is.
def test_ccache_env_opt_in_without_binary(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Explicit IDF_CCACHE_ENABLE=1 forces it on; without a usable binary
# idf.py silently skips ccache, so this branch must say so out loud.
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3:
env = _ccache_env()
assert "IDF_CCACHE_ENABLE" not in env
assert env["IDF_CCACHE_ENABLE"] == "1"
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
assert env["CCACHE_DEPEND"] == "1"
assert "no ccache binary is on PATH" in caplog.text
def test_ccache_env_opt_in_with_working_binary(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Forced on with a working binary: no warning fires at all.
ccache = tmp_path / "ccache"
ccache.touch()
p1, p2, p3 = _ccache_patches(tmp_path, str(ccache), tmp_path / "build")
with (
patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True),
patch("esphome.espidf.framework.shutil.which", return_value=str(ccache)),
patch("esphome.espidf.framework.tool_version_runs", return_value=True),
p1,
p2,
p3,
):
env = _ccache_env()
assert env["IDF_CCACHE_ENABLE"] == "1"
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_ccache_env_opt_in_with_rejected_binary(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Forced on with a present-but-rejected binary: idf.py does its own
# PATH lookup and uses it anyway; the warning must say so, not claim
# the build runs without ccache.
# A present but non-executable file: the real probe fails and logs
# the forced-on message (patching the probe would silence it)
broken = tmp_path / "broken-ccache"
broken.touch()
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
with (
patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True),
patch("esphome.espidf.framework.shutil.which", return_value=str(broken)),
p1,
p2,
p3,
):
env = _ccache_env()
assert env["IDF_CCACHE_ENABLE"] == "1"
assert "idf.py will use it anyway" in caplog.text
# Exactly one story: the resolver's contradictory "compiling without
# ccache" must not precede it
assert "compiling without ccache" not in caplog.text
def test_ccache_env_honors_shared_esphome_opt_out(tmp_path: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache here too; the shared policy
must not apply to every backend except this one."""
_p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
env_vars = {"ESPHOME_CCACHE_ENABLE": "0", "PATH": "/usr/bin"}
with patch.dict("os.environ", env_vars, clear=True), p2, p3:
# The real resolver runs so the opt-out parse is exercised
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
@pytest.mark.parametrize("value", ["off", "no"])
def test_ccache_env_idf_knob_parses_strictly(tmp_path: Path, value: str) -> None:
"""IDF_CCACHE_ENABLE uses the same strict table as the shared knob, so
"off" disables instead of reading as truthy."""
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": value}, clear=True), p1, p2, p3:
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
def test_ccache_env_idf_knob_unrecognized_warns_and_defers(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver,
and is not forwarded to idf.py as truthy."""
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
env_vars = {"IDF_CCACHE_ENABLE": "enabled"}
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
env = _ccache_env()
assert "unrecognized IDF_CCACHE_ENABLE" in caplog.text
assert env["IDF_CCACHE_ENABLE"] == "1"
def test_ccache_env_idf_knob_wins_over_shared_opt_out(tmp_path: Path) -> None:
"""IDF_CCACHE_ENABLE=1 takes precedence over ESPHOME_CCACHE_ENABLE=0."""
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
env_vars = {"IDF_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_ENABLE": "0"}
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
env = _ccache_env()
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
assert env["IDF_CCACHE_ENABLE"] == "1"
def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None:
@@ -1224,6 +1784,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None:
assert _check_stamp(f, {"a": "1"}) is False
def test_read_stamp_corrupt_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A corrupt stamp forces a full reinstall on every build, so it warns
# where the normal missing-file case stays silent.
f = tmp_path / "s.json"
f.write_text("{ not json", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "Ignoring corrupt stamp file" in caplog.text
def test_read_stamp_unreadable_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# An I/O fault (permissions, disk error) is distinguished from a simply
# missing stamp with a warning before falling back to reinstall.
f = tmp_path / "s.json"
f.write_text(json.dumps({"a": "1"}), encoding="utf-8")
with (
patch.object(Path, "open", side_effect=PermissionError("denied")),
caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"),
):
assert _read_stamp(f) is None
assert "Could not read stamp file" in caplog.text
def test_read_stamp_non_dict_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Well-formed JSON that is not an object is a fault, not a first install;
# it must leave a trace before forcing reinstalls.
f = tmp_path / "s.json"
f.write_text("null", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "unexpected type NoneType" in caplog.text
def test_read_stamp_missing_file_is_silent(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Missing stamps are the normal first-install case and must not log.
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"):
assert _read_stamp(tmp_path / "nope.json") is None
assert "stamp file" not in caplog.text
def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None:
_write_idf_version_txt(tmp_path, "5.1.2")
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n"
-264
View File
@@ -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
+211
View File
@@ -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)
+457 -113
View File
@@ -2,6 +2,8 @@
# pylint: disable=protected-access
from collections.abc import Iterator
from contextlib import contextmanager
import json
import os
from pathlib import Path
@@ -10,7 +12,13 @@ from unittest.mock import patch
import pytest
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
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
@@ -50,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():
@@ -61,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]:
@@ -73,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)
@@ -86,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()
@@ -97,114 +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('{"cc_path": "cached-gcc", "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 == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
"""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())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)
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."""
@@ -213,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()
@@ -238,6 +186,21 @@ 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.
@@ -309,6 +272,387 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
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
+397 -22
View File
@@ -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,17 @@ 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
)
# The data phase timeout must outlast the device's 105 s data timeout
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
assert espota2.DATA_PHASE_TIMEOUT > 105.0
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_with_compression(mock_socket: Mock) -> None:
@@ -530,6 +601,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 +773,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 +1001,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 +1013,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")
+521
View File
@@ -0,0 +1,521 @@
"""Unit tests for encrypted OTA uploads in esphome.espota2.
A fake device implementing the responder side of the wire protocol (via
noiseprotocol, which esphome already has through aioesphomeapi) serves a real
TCP loopback connection, so these exercise the actual handshake, framing, and
cipher interop of the client code. Tests that need the client-side crypto skip
when the installed aioesphomeapi predates the noise module.
"""
from __future__ import annotations
import base64
from collections.abc import Callable
import hashlib
import io
import logging
from pathlib import Path
import socket
import sys
import threading
from typing import Any
from unittest.mock import Mock, patch
import pytest
from esphome import espota2
PSK = base64.b64encode(bytes(range(32))).decode()
OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode()
MAGIC = bytes(espota2.MAGIC_BYTES)
def _recv_exact(sock: socket.socket, amount: int) -> bytes:
data = b""
while len(data) < amount:
chunk = sock.recv(amount - len(data))
if not chunk:
raise ConnectionError("client closed")
data += chunk
return data
def _frame(payload: bytes) -> bytes:
return (
bytes([espota2.NOISE_FRAME_INDICATOR, len(payload) >> 8, len(payload) & 0xFF])
+ payload
)
def _send_frame(sock: socket.socket, payload: bytes) -> None:
sock.sendall(_frame(payload))
def _recv_frame(sock: socket.socket) -> bytes:
header = _recv_exact(sock, 3)
assert header[0] == 0x01
return _recv_exact(sock, (header[1] << 8) | header[2])
class FakeEncryptedDevice(threading.Thread):
"""Responder side of the encrypted OTA wire protocol."""
def __init__(
self,
psk: str = PSK,
version: int = 2,
offer_noise: bool = True,
require_noise: bool = True,
prologue_features_override: int | None = None,
connections: int = 1,
drop_handshakes: int = 0,
) -> None:
super().__init__(daemon=True)
self.connections = connections
self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times
self.psk = psk
self.version = version
self.offer_noise = offer_noise
self.require_noise = require_noise
self.prologue_features_override = prologue_features_override
self.received: bytes | None = None
self.error: Exception | None = None
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
def run(self) -> None:
try:
for _ in range(self.connections):
sock, _ = self.listener.accept()
sock.settimeout(10)
with sock:
self._serve(sock)
except Exception as err: # noqa: BLE001 - surfaced via join_and_check
self.error = err
finally:
self.listener.close()
def join_and_check(self) -> None:
self.join(timeout=10)
assert not self.is_alive(), "fake device did not finish"
if self.error is not None:
raise self.error
def _serve(self, sock: socket.socket) -> None:
assert _recv_exact(sock, 5) == MAGIC
sock.sendall(bytes([espota2.RESPONSE_OK, self.version]))
features = _recv_exact(sock, 1)[0]
noise_negotiated = bool(
features & espota2.CLIENT_FEATURE_SUPPORTS_NOISE
and features & espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
if self.require_noise and not noise_negotiated:
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
return
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
if not (noise_negotiated and self.offer_noise):
# A device that does not require encryption continues in
# plaintext whatever the client asked for, like older firmware
try:
self._transfer(
lambda byte: sock.sendall(bytes([byte])),
lambda length: _recv_exact(sock, length),
lambda remaining: _recv_exact(
sock, min(remaining, espota2.UPLOAD_BLOCK_SIZE)
),
)
except ConnectionError:
# A keyed client without fallback fails closed and hangs up
if noise_negotiated and not self.offer_noise:
return
raise
return
from cryptography.exceptions import InvalidTag
from noise.connection import NoiseConnection
prologue_features = (
features
if self.prologue_features_override is None
else self.prologue_features_override
)
prologue = (
espota2.NOISE_PROLOGUE_INIT
+ MAGIC
+ bytes([espota2.RESPONSE_OK, self.version, prologue_features])
+ bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])
)
proto = NoiseConnection.from_name(b"Noise_NNpsk0_25519_ChaChaPoly_SHA256")
proto.set_as_responder()
proto.set_psks(base64.b64decode(self.psk))
proto.set_prologue(prologue)
proto.start_handshake()
msg1 = _recv_frame(sock)
assert msg1[0] == 0x00
if self.drop_handshakes > 0:
self.drop_handshakes -= 1
return # a transport fault: the socket closes with no reply
try:
proto.read_message(msg1[1:])
except InvalidTag:
_send_frame(sock, b"\x01" + espota2.NOISE_MAC_FAILURE_REASON.encode())
return
_send_frame(sock, b"\x00" + bytes(proto.write_message()))
def send_byte(byte: int) -> None:
_send_frame(sock, proto.encrypt(bytes([byte])))
def recv_unit(length: int) -> bytes:
plaintext = proto.decrypt(_recv_frame(sock))
assert len(plaintext) == length, "control units must be one per frame"
return plaintext
def recv_data(_remaining: int) -> bytes:
plaintext = proto.decrypt(_recv_frame(sock))
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
return plaintext
self._transfer(send_byte, recv_unit, recv_data)
def _transfer(
self,
send_byte: Callable[[int], None],
recv_unit: Callable[[int], bytes],
recv_data: Callable[[int], bytes],
) -> None:
"""The post-handshake exchange, identical over both transports."""
send_byte(espota2.RESPONSE_AUTH_OK)
recv_unit(1) # ota type
size = int.from_bytes(recv_unit(4), "big")
send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK)
md5_hex = recv_unit(32)
send_byte(espota2.RESPONSE_BIN_MD5_OK)
received = b""
acked = 0
while len(received) < size:
received += recv_data(size - len(received))
if self.version >= espota2.OTA_VERSION_2_0:
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
len(received) == size and acked < size
):
send_byte(espota2.RESPONSE_CHUNK_OK)
acked += espota2.UPLOAD_BLOCK_SIZE
assert hashlib.md5(received).hexdigest().encode() == md5_hex
send_byte(espota2.RESPONSE_RECEIVE_OK)
send_byte(espota2.RESPONSE_UPDATE_END_OK)
assert recv_unit(1) == bytes([espota2.RESPONSE_OK])
self.received = received
def _upload(
device: FakeEncryptedDevice,
firmware: bytes,
noise_psk: str | None,
plaintext_fallback: bool = False,
) -> None:
device.start()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(("127.0.0.1", device.port))
try:
espota2.perform_ota(
sock,
None,
io.BytesIO(firmware),
Path("firmware.bin"),
noise_psk=noise_psk,
plaintext_fallback=plaintext_fallback,
)
finally:
sock.close()
def _run_ota(
device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str
) -> int:
"""Drive the retry loop, which is where the plaintext fallback reconnects."""
path = tmp_path / "firmware.bin"
path.write_bytes(firmware)
device.start()
rc, _ = espota2.run_ota(
"127.0.0.1",
device.port,
None,
path,
noise_psk=noise_psk,
plaintext_fallback=True,
)
return rc
def test_encrypted_upload_success() -> None:
"""A full encrypted v2 upload spanning several 8192-byte blocks."""
pytest.importorskip("aioesphomeapi.noise")
firmware = bytes(range(256)) * 80 # 20480 bytes, crosses chunk-ack boundaries
device = FakeEncryptedDevice()
with patch("time.sleep"):
_upload(device, firmware, PSK)
device.join_and_check()
assert device.received == firmware
def test_encrypted_upload_version_1() -> None:
"""Version 1 protocol (no chunk acks) works through the noise transport."""
pytest.importorskip("aioesphomeapi.noise")
firmware = b"v1 firmware image" * 100
device = FakeEncryptedDevice(version=1)
with patch("time.sleep"):
_upload(device, firmware, PSK)
device.join_and_check()
assert device.received == firmware
def test_wrong_key_fails_with_clear_error() -> None:
"""A key mismatch surfaces the device's handshake reject readably."""
pytest.importorskip("aioesphomeapi.noise")
device = FakeEncryptedDevice(psk=OTHER_PSK)
with pytest.raises(espota2.OTAError, match="encryption key correct"):
_upload(device, b"firmware", PSK)
device.join_and_check()
def test_tampered_negotiation_breaks_handshake() -> None:
"""A negotiation byte differing between the sides breaks the prologue MAC."""
pytest.importorskip("aioesphomeapi.noise")
device = FakeEncryptedDevice(
prologue_features_override=espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
with pytest.raises(espota2.OTAError, match="encryption key correct"):
_upload(device, b"firmware", PSK)
device.join_and_check()
def test_client_fails_closed_when_device_lacks_encryption() -> None:
"""With a key configured, a device not offering noise aborts the upload."""
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
with pytest.raises(espota2.OTAError, match="refusing to send the image"):
_upload(device, b"firmware", PSK)
device.join_and_check()
# Remove before 2027.3.0
def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None:
"""The api key is tried opportunistically; an older device that cannot
encrypt still gets its update, with a warning."""
firmware = b"firmware"
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
with patch("time.sleep"), caplog.at_level(logging.WARNING):
_upload(device, firmware, PSK, plaintext_fallback=True)
device.join_and_check()
assert device.received == firmware
assert any("fallback is removed in 2027.3.0" in r.message for r in caplog.records)
# Remove before 2027.3.0
@pytest.mark.parametrize(
("device_kwargs", "expected_rc", "fell_back"),
[
# A wrong key against an offering device reconnects in plaintext
({"psk": OTHER_PSK, "require_noise": False, "connections": 2}, 0, True),
# The plaintext retry is refused by a device that requires encryption
({"psk": OTHER_PSK, "require_noise": True, "connections": 2}, 1, True),
# A dropped connection inside the handshake is retried encrypted
({"require_noise": False, "connections": 2, "drop_handshakes": 1}, 0, False),
# A second transport fault inside the handshake falls back
({"require_noise": False, "connections": 3, "drop_handshakes": 2}, 0, True),
],
ids=["wrong_key", "wrong_key_required", "one_fault", "two_faults"],
)
def test_fallback_through_the_retry_loop(
caplog: pytest.LogCaptureFixture,
tmp_path: Path,
device_kwargs: dict[str, Any],
expected_rc: int,
fell_back: bool,
) -> None:
pytest.importorskip("aioesphomeapi.noise")
firmware = b"firmware"
device = FakeEncryptedDevice(**device_kwargs)
with patch("time.sleep"), caplog.at_level(logging.WARNING):
rc = _run_ota(device, firmware, tmp_path, PSK)
device.join_and_check()
assert rc == expected_rc
assert (device.received == firmware) is (expected_rc == 0)
assert (
any("Retrying in plaintext" in r.message for r in caplog.records) is fell_back
)
if expected_rc == 1:
assert any("requires an encrypted OTA" in r.message for r in caplog.records)
def test_plaintext_client_gets_encryption_required_error() -> None:
"""A client without a key gets the device's 0x94 error message."""
device = FakeEncryptedDevice()
with pytest.raises(espota2.OTAError, match="requires an encrypted OTA"):
_upload(device, b"firmware", None)
device.join_and_check()
def test_missing_aioesphomeapi_noise_module_message() -> None:
"""An aioesphomeapi without the noise module produces a clear error."""
with (
patch.dict(sys.modules, {"aioesphomeapi.noise": None}),
pytest.raises(espota2.OTAError, match="requires a newer aioesphomeapi"),
):
espota2.NoiseSocketWrapper(Mock(), PSK, b"prologue")
class ScriptedSocket:
"""Serves scripted recv chunks; b"" means the peer closed."""
def __init__(self, *chunks: bytes | Exception) -> None:
self.chunks = list(chunks)
self.sent: list[bytes] = []
def sendall(self, data: bytes) -> None:
self.sent.append(data)
def settimeout(self, timeout: float) -> None:
pass
def recv(self, amount: int) -> bytes:
if not self.chunks:
return b""
chunk = self.chunks[0]
if isinstance(chunk, Exception):
self.chunks.pop(0)
raise chunk
take, rest = chunk[:amount], chunk[amount:]
if rest:
self.chunks[0] = rest
else:
self.chunks.pop(0)
return take
def _wrapper(*chunks: bytes | Exception) -> espota2.NoiseSocketWrapper:
pytest.importorskip("aioesphomeapi.noise")
return espota2.NoiseSocketWrapper(ScriptedSocket(*chunks), PSK, b"prologue")
def test_wrapper_rejects_malformed_psk() -> None:
pytest.importorskip("aioesphomeapi.noise")
with pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"):
espota2.NoiseSocketWrapper(ScriptedSocket(), "not-base64!!!", b"prologue")
def test_handshake_socket_error_is_network_error() -> None:
wrapper = _wrapper(OSError("boom"))
with pytest.raises(espota2.OTANetworkError, match="noise handshake"):
wrapper.do_handshake()
def test_handshake_closed_at_frame_boundary() -> None:
wrapper = _wrapper()
with pytest.raises(espota2.OTANetworkError, match="closed connection during"):
wrapper.do_handshake()
def test_handshake_reject_with_other_reason() -> None:
wrapper = _wrapper(_frame(b"\x01Handshake error"))
with pytest.raises(
espota2.OTAError, match="rejected the noise handshake: Handshake error"
):
wrapper.do_handshake()
def test_handshake_garbage_second_message() -> None:
"""A valid-looking point with a garbage MAC fails cleanly."""
wrapper = _wrapper(_frame(b"\x00" + bytes(range(48))))
with pytest.raises(
espota2.OTAError, match="handshake failed; is the OTA encryption key"
):
wrapper.do_handshake()
def test_handshake_invalid_curve_point() -> None:
"""An all-zero x25519 point is rejected as a clean error, not a crash."""
wrapper = _wrapper(_frame(b"\x00" + bytes(48)))
with pytest.raises(
espota2.OTAError, match="handshake failed; is the OTA encryption key"
):
wrapper.do_handshake()
def test_recv_closed_at_frame_boundary_returns_empty() -> None:
wrapper = _wrapper()
assert wrapper.recv(1) == b""
def test_recv_corrupt_frame_is_retryable_network_error() -> None:
from cryptography.exceptions import InvalidTag
wrapper = _wrapper(_frame(b"ciphertext"))
wrapper._decrypt = Mock(decrypt=Mock(side_effect=InvalidTag()))
with pytest.raises(espota2.OTANetworkError, match="decryption failed"):
wrapper.recv(1)
def test_wrapper_blocks_unencrypted_socket_methods() -> None:
"""Byte-moving socket methods must not bypass the encrypted transport."""
wrapper = _wrapper()
# The harmless socket controls pass through to the wrapped socket
wrapper._sock = Mock()
wrapper.settimeout(1)
wrapper._sock.settimeout.assert_called_once_with(1)
wrapper.setsockopt(6, 1, 1)
wrapper._sock.setsockopt.assert_called_once_with(6, 1, 1)
wrapper.close()
wrapper._sock.close.assert_called_once_with()
with pytest.raises(AttributeError):
_ = wrapper.send
with pytest.raises(AttributeError):
_ = wrapper.recv_into
def test_recv_empty_plaintext_frame_is_protocol_error() -> None:
"""A MAC-only frame decrypts to nothing; b'' from recv must mean close."""
wrapper = _wrapper(_frame(bytes(16)))
wrapper._decrypt = Mock(decrypt=Mock(return_value=b""))
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
wrapper.recv(1)
def test_recv_frame_bad_indicator_is_retryable() -> None:
wrapper = _wrapper(b"\x02\x00\x01x")
with pytest.raises(espota2.OTANetworkError, match="Bad noise frame indicator"):
wrapper._recv_frame()
def test_recv_frame_zero_length_is_retryable() -> None:
wrapper = _wrapper(bytes([espota2.NOISE_FRAME_INDICATOR, 0, 0]))
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
wrapper._recv_frame()
def test_perform_ota_blank_key_refuses_plaintext() -> None:
with pytest.raises(espota2.OTAError, match="empty OTA encryption key"):
espota2.perform_ota(
ScriptedSocket(), None, io.BytesIO(b"x"), Path("f.bin"), noise_psk=""
)
def test_recv_exact_closed_mid_frame() -> None:
wrapper = _wrapper(_frame(b"partial")[:5])
with pytest.raises(OSError, match="closed inside a noise frame"):
wrapper._recv_frame()
def test_recv_serves_buffered_plaintext_without_new_frame() -> None:
"""A second recv drains the decrypted buffer without reading another frame."""
wrapper = _wrapper(_frame(b"ciphertext"))
wrapper._decrypt = Mock(decrypt=Mock(return_value=b"AB"))
assert wrapper.recv(1) == b"A" # reads and decrypts one frame
assert wrapper.recv(1) == b"B" # served from the buffer, no new frame
wrapper._decrypt.decrypt.assert_called_once()
+452 -27
View File
@@ -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()
+588 -96
View File
@@ -2,6 +2,7 @@
# pylint: disable=protected-access
import gzip
import hashlib
import importlib.util
import io
@@ -12,7 +13,9 @@ from pathlib import Path
import subprocess
import sys
import tarfile
from unittest.mock import MagicMock, Mock, patch
import threading
import time
from unittest.mock import MagicMock, Mock, call, patch
import zipfile
import pytest
@@ -22,12 +25,14 @@ from esphome import framework_helpers
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_BatchDownloadProgress,
_detect_archive_root,
_rename_with_retry,
_tar_extract_all,
_zip_extract_all,
archive_extract_all,
create_venv,
download_and_extract,
download_from_mirrors,
download_with_resume,
get_project_compile_flags,
@@ -36,6 +41,7 @@ from esphome.framework_helpers import (
get_python_env_executable_path,
get_system_python_path,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
@@ -187,6 +193,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None:
assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42"
def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None:
"""A PYTHONPATH from the parent environment must not leak into subprocesses."""
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}):
run_command(["cmd"])
assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"]
def test_run_command_env_pythonpath_preferred_over_pop(
mock_subprocess_run: Mock,
) -> None:
"""A PYTHONPATH set explicitly via ``env`` is passed through."""
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}):
run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"})
assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools"
def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None:
mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="")
run_command(["cmd"], cwd=str(tmp_path))
@@ -515,16 +539,23 @@ class TestArchiveExtractAll:
# ---------------------------------------------------------------------------
def _mock_response(content: bytes, ok: bool = True) -> MagicMock:
def _mock_response(
content: bytes, ok: bool = True, status: int | None = None
) -> MagicMock:
"""A fake requests response. The HTTPError carries the response (as
``raise_for_status`` on a real response) so the transient classifier
can see its ``status``; failures default to a permanent 404."""
if status is None:
status = 200 if ok else 404
r = MagicMock()
r.__enter__.return_value = r
r.__exit__.return_value = False
r.status_code = 200
r.status_code = status
r.ok = ok
if ok:
r.raise_for_status.return_value = None
else:
r.raise_for_status.side_effect = req.HTTPError("503")
r.raise_for_status.side_effect = req.HTTPError(str(status), response=r)
r.headers = {"content-length": "0"} # suppress ProgressBar
r.iter_content.return_value = [content] if content else []
return r
@@ -1086,6 +1117,218 @@ class TestDownloadWithResume:
assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data"
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
"""With a callback no bar is drawn; the callback sees the running
byte count of this file, then its final verified size."""
dest = tmp_path / "tool.tar.gz"
resp = _mock_response(b"")
resp.headers = {"content-length": "7"}
resp.iter_content.return_value = [b"1234", b"567"]
seen: list[int] = []
with (
patch("requests.get", return_value=resp),
patch("esphome.framework_helpers.ProgressBar") as bar_cls,
):
download_with_resume(
"https://example.com/t", dest, size=7, progress=seen.append
)
assert seen == [0, 4, 7, 7]
bar_cls.assert_not_called()
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
seen: list[int] = []
with patch("requests.get", return_value=_resumed_response(b"678")):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
)
assert seen[0] == 5
assert seen[-1] == 8
def test_progress_callback_credits_already_complete_download(
self, tmp_path: Path
) -> None:
"""A verified dest from an earlier run still counts toward the batch."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"12345678")
seen: list[int] = []
with patch("requests.get") as mock_get:
download_with_resume(
"https://example.com/t", dest, size=8, progress=seen.append
)
mock_get.assert_not_called()
assert seen == [8]
def test_run_batch_downloads_ctrl_c_aborts_in_flight_jobs() -> None:
"""Ctrl-C cancels in-flight downloads at their next tick instead of
letting non-daemon workers download to completion."""
started = threading.Event()
ticks: list[int] = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def slow_download(tracker) -> None:
started.set()
for i in range(500):
tracker(i)
ticks.append(i)
time.sleep(0.01)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("slow", 0, slow_download)],
max_workers=2,
)
# Uncancelled, slow_download alone takes ~5s
assert time.monotonic() - t0 < 3
assert len(ticks) < 500
def test_cancellation_escapes_broad_except_in_fetch() -> None:
"""A fetch that wraps its work in except Exception cannot swallow the
Ctrl-C sentinel (it is a BaseException)."""
from esphome.framework_helpers import _BatchDownloadCancelled
started = threading.Event()
swallowed = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def greedy_fetch(tracker) -> None:
started.set()
try:
for i in range(500):
tracker(i)
time.sleep(0.01)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
swallowed.append(err)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("greedy", 0, greedy_fetch)],
max_workers=2,
)
assert time.monotonic() - t0 < 3
assert not swallowed
assert issubclass(_BatchDownloadCancelled, BaseException)
assert not issubclass(_BatchDownloadCancelled, Exception)
def test_logging_guard_ends_the_bar_row_before_a_record() -> None:
r"""A worker warning gets its own line instead of the bar's \r row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("mirror retry")
# The partial 50% frame ended its line before the record was emitted
assert stream.getvalue().endswith("50% \n")
# And the next tick redraws the frame on a fresh row
progress.tracker()(2)
assert stream.getvalue().endswith("70% ")
def test_logging_guard_without_a_bar_is_a_no_op() -> None:
"""An unknown total draws no bar; the guard passes records through."""
progress = _BatchDownloadProgress("Downloading", 0)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("plain record")
def test_cancellable_sleep_sleeps_between_ticks() -> None:
"""An uncancelled backoff actually waits out its delay in slices."""
from esphome.framework_helpers import _cancellable_sleep
ticks: list[int] = []
t0 = time.monotonic()
_cancellable_sleep(0.05, ticks.append, 3)
assert time.monotonic() - t0 >= 0.05
assert ticks and all(t == 3 for t in ticks)
def test_cancellable_sleep_aborts_at_the_tick() -> None:
"""A backoff sleep observes the cancellation raise promptly."""
from esphome.framework_helpers import _BatchDownloadCancelled, _cancellable_sleep
def cancelled_tick(done: int) -> None:
raise _BatchDownloadCancelled
t0 = time.monotonic()
with pytest.raises(_BatchDownloadCancelled):
_cancellable_sleep(30, cancelled_tick, 0)
assert time.monotonic() - t0 < 1
class Test_BatchDownloadProgress:
def test_sums_trackers_into_one_bar(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 100)
a = progress.tracker()
b = progress.tracker()
a(10)
b(20)
a(30)
a(0) # a restart from zero takes that file's bytes back out
bar_cls.assert_called_once_with("Downloading")
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
assert updates == [0.1, 0.3, 0.5, 0.2]
def test_clamps_at_one(self) -> None:
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(25)
assert bar_cls.return_value.update.call_args[0][0] == 1
def test_unknown_total_draws_nothing(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 0)
progress.tracker()(5)
progress.done()
bar_cls.assert_not_called()
def test_done_ends_an_unfinished_bar(self) -> None:
"""A batch that stops short of 100% (a failed archive) still ends its
line so the next log message starts on a fresh row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
progress.done()
assert stream.getvalue().endswith("50% \n")
def test_done_before_any_frame_writes_nothing(self) -> None:
"""A batch aborted before any tracker fired must not emit a stray
newline for a bar that was never drawn."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
_BatchDownloadProgress("Downloading", 10).done()
assert stream.getvalue() == ""
def test_done_after_full_bar_adds_nothing(self) -> None:
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(10)
progress.done()
assert stream.getvalue().endswith("100% Done...\r\n")
class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
@@ -1098,6 +1341,22 @@ class TestDownloadFromMirrors:
assert url == "https://example.com/f"
assert target.read_bytes() == b"filedata"
def test_progress_callback_reports_bytes(self, tmp_path: Path) -> None:
"""The library prefetch's production path: the mirror download ticks
the caller's tracker instead of drawing its own bar."""
target = tmp_path / "f.bin"
ticks: list[int] = []
with patch(
"requests.get",
return_value=_mock_response(b"filedata"),
):
url = download_from_mirrors(
["https://example.com/f"], {}, target, progress=ticks.append
)
assert url == "https://example.com/f"
assert target.read_bytes() == b"filedata"
assert ticks and ticks[-1] == len(b"filedata")
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
with patch(
"requests.get",
@@ -1203,8 +1462,8 @@ class TestDownloadFromMirrors:
ei.value
)
def test_falls_back_to_second_mirror(self) -> None:
buf = io.BytesIO()
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
target = tmp_path / "f.bin"
with patch(
"requests.get",
side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")],
@@ -1212,18 +1471,18 @@ class TestDownloadFromMirrors:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
target,
)
assert url == "https://mirror2.com/f"
assert buf.getvalue() == b"second"
assert target.read_bytes() == b"second"
def test_mid_stream_drop_resumes_same_mirror(self) -> None:
def test_mid_stream_drop_resumes_same_mirror(self, tmp_path: Path) -> None:
"""A mid-stream failure retries the same mirror with Range and
If-Range headers, keeping the bytes already received, before falling
to the next."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
buf = io.BytesIO()
target = tmp_path / "f.bin"
with patch(
"requests.get",
side_effect=[first, _resumed_response(b"5678")],
@@ -1231,10 +1490,10 @@ class TestDownloadFromMirrors:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
target,
)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"12345678"
assert target.read_bytes() == b"12345678"
assert mock_get.call_count == 2
assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f"
# the resume is conditional on the content being unchanged
@@ -1243,48 +1502,6 @@ class TestDownloadFromMirrors:
"If-Range": '"v1"',
}
def test_mid_stream_drop_without_validator_restarts(self) -> None:
"""A server offering no ETag/Last-Modified cannot be resumed safely;
the retry restarts from zero instead of stitching unverified bytes."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_drop_after_last_byte_recovers_via_416(self) -> None:
"""A connection drop after the final body byte leaves a complete file;
the retry's 416 answer plus the length check turn it into success
instead of a wasted refetch."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "4"}
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
buf = io.BytesIO()
with patch("requests.get", side_effect=[first, r416]) as mock_get:
url = download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"1234"
assert mock_get.call_count == 2
def test_mirror_drop_without_length_restarts(self) -> None:
"""With no content-length there is no way to prove a stitched file
complete, so the retry restarts even though a validator exists."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"1234", etag='"v1"'),
_mock_response(b"full"),
],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None:
"""A path target routes through download_with_resume: a part file and
metadata from a previous run resume instead of restarting."""
@@ -1316,32 +1533,14 @@ class TestDownloadFromMirrors:
assert url == "https://mirror2.com/f"
assert dest.read_bytes() == b"data"
def test_resumed_short_body_fails_length_check(self) -> None:
"""A stitched file whose final length disagrees with the advertised
total is rejected instead of reported as success."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
# the resume ends early (5 of 8 bytes); the poisoned part is then
# discarded and the fresh retry also delivers a short body
short_resume = _resumed_response(b"5")
short_fresh = _mock_response(b"56")
short_fresh.headers = {**short_fresh.headers, "content-length": "8"}
buf = io.BytesIO()
with (
patch("requests.get", side_effect=[first, short_resume, short_fresh]),
pytest.raises(EsphomeError, match="all mirrors"),
):
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None:
"""Bytes from a mirror that failed all attempts must not leak into the
next mirror's download (no bogus Range request, fresh content)."""
exhausted = [_interrupted_response(b"AAAA", etag='"a1"')]
for _ in range(2):
r = _interrupted_response(b"BB")
r.status_code = 206
exhausted.append(r)
buf = io.BytesIO()
def test_failed_mirror_leftovers_not_resumed_on_next_mirror(
self, tmp_path: Path
) -> None:
"""A part file left by a mirror that failed all attempts must not be
stitched onto the next mirror's download (its meta names the other
URL, so the retry restarts from zero without a Range request)."""
exhausted = [_interrupted_response(b"AAAA") for _ in range(3)]
target = tmp_path / "f.bin"
with patch(
"requests.get",
side_effect=exhausted + [_mock_response(b"clean")],
@@ -1349,15 +1548,17 @@ class TestDownloadFromMirrors:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
target,
)
assert url == "https://mirror2.com/f"
assert buf.getvalue() == b"clean"
assert target.read_bytes() == b"clean"
# the second mirror starts fresh, without a Range header
assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f"
assert "Range" not in mock_get.call_args_list[3][1]["headers"]
def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None:
def test_all_mirrors_fail_raises_error_listing_every_attempt(
self, tmp_path: Path
) -> None:
with (
patch(
"requests.get",
@@ -1368,7 +1569,7 @@ class TestDownloadFromMirrors:
download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
io.BytesIO(),
tmp_path / "out.bin",
)
# Every attempted URL appears in the message, and the first mirror's
# exception (the primary URL, usually the one that matters) is chained.
@@ -1384,16 +1585,6 @@ class TestDownloadFromMirrors:
with pytest.raises(TypeError, match="target must be"):
download_from_mirrors(["https://example.com/f"], {}, 42) # type: ignore[arg-type]
def test_file_like_target_written(self) -> None:
buf = io.BytesIO()
with patch(
"requests.get",
return_value=_mock_response(b"bytes"),
):
download_from_mirrors(["https://example.com/f"], {}, buf)
buf.seek(0)
assert buf.read() == b"bytes"
def test_progress_bar_shown_when_content_length_known(self, tmp_path: Path) -> None:
r = _mock_response(b"1234567890")
r.headers = {"content-length": "10"}
@@ -1419,6 +1610,213 @@ class TestDownloadFromMirrors:
assert target.exists()
assert target.read_bytes() == b""
def test_transient_failure_retries_mirror_sweep(self, tmp_path: Path) -> None:
"""A transient connect error on the only applicable mirror retries the
whole mirror list with backoff instead of failing the build."""
target = tmp_path / "idf.tar.xz"
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("Remote end closed connection"),
_mock_response(b"data"),
],
) as mock_get,
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
):
url = download_from_mirrors(["https://mirror1.com/f"], {}, target)
assert url == "https://mirror1.com/f"
assert target.read_bytes() == b"data"
assert mock_get.call_count == 2
mock_sleep.assert_called_once_with(2)
def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None:
"""The backoff tick carries the bytes already in the part file, so a
combined bar holds steady instead of rewinding to zero."""
dest = tmp_path / "out.bin"
(tmp_path / "out.bin.part").write_bytes(b"12345")
ticks: list[int] = []
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("down"),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
):
download_from_mirrors(
["https://mirror1.com/f"], {}, dest, progress=ticks.append
)
assert mock_sleep.call_args == call(2, ticks.append, 5)
def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None:
"""An HTTP 404 will not heal on its own; fail after a single pass."""
with (
patch(
"requests.get", return_value=_mock_response(b"", ok=False, status=404)
) as mock_get,
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
pytest.raises(EsphomeError, match="all mirrors"),
):
download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin")
assert mock_get.call_count == 1
mock_sleep.assert_not_called()
def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None:
"""A persistent transient error gives up after the configured number
of passes, with 2s/4s backoff, and still lists the attempted URL."""
with (
patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get,
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin")
assert mock_get.call_count == 3
assert mock_sleep.call_args_list == [call(2), call(4)]
assert "https://mirror1.com/f" in str(ei.value)
def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None:
"""One mirror 404s permanently while another hits a transient error;
the transient failure makes the whole list worth another pass."""
dest = tmp_path / "out.bin"
with (
patch(
"requests.get",
side_effect=[
_mock_response(b"", ok=False, status=404),
req.ConnectionError("down"),
_mock_response(b"", ok=False, status=404),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
):
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest
)
assert url == "https://mirror2.com/f"
assert dest.read_bytes() == b"data"
mock_sleep.assert_called_once_with(2)
def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None:
"""A real 5xx (response attached to the HTTPError) is transient."""
dest = tmp_path / "out.bin"
with (
patch(
"requests.get",
side_effect=[
_mock_response(b"", ok=False, status=503),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
):
url = download_from_mirrors(["https://mirror1.com/f"], {}, dest)
assert url == "https://mirror1.com/f"
assert dest.read_bytes() == b"data"
mock_sleep.assert_called_once_with(2)
def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None:
"""A failure mode that changes between sweeps stays in the final
error; the first failure (the one that started the retries) is
chained as the cause."""
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("dropped by middlebox"),
_mock_response(b"", ok=False, status=404),
],
),
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin")
assert "dropped by middlebox" in str(ei.value)
assert "404" in str(ei.value)
assert isinstance(ei.value.__cause__, req.ConnectionError)
mock_sleep.assert_called_once_with(2)
def test_exhausted_mid_stream_attempts_not_swept(self, tmp_path: Path) -> None:
"""A mirror that spent all its mid-stream attempts fails permanently
instead of re-arming the sweep, and its part file survives so the
next esphome run resumes it."""
with (
patch(
"requests.get",
side_effect=[_interrupted_response(b"1234") for _ in range(3)],
) as mock_get,
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
pytest.raises(EsphomeError, match="after 3 attempts"),
):
download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin")
assert mock_get.call_count == 3
mock_sleep.assert_not_called()
assert (tmp_path / "out.bin.part").exists()
class TestDownloadAndExtract:
def test_downloads_extracts_and_deletes_archive(self, tmp_path: Path) -> None:
content = gzip.compress(
_make_tar([_reg("file.txt")], {"file.txt": b"data"}).getvalue()
)
dest = tmp_path / "out"
with patch("requests.get", return_value=_mock_response(content)):
url = download_and_extract(
["https://example.com/lib.tar.gz"],
{},
tmp_path / "lib.archive",
dest,
)
assert url == "https://example.com/lib.tar.gz"
assert (dest / "file.txt").read_bytes() == b"data"
# the archive is consumed; only the extraction remains
assert not (tmp_path / "lib.archive").exists()
def test_locked_archive_does_not_mask_result(self, tmp_path: Path) -> None:
"""A cleanup unlink blocked by e.g. an AV handle (Windows) must not
replace the extraction result; the archive simply survives."""
content = gzip.compress(
_make_tar([_reg("file.txt")], {"file.txt": b"data"}).getvalue()
)
real_unlink = Path.unlink
def locked_unlink(self: Path, missing_ok: bool = False) -> None:
if self.name.endswith(".archive"):
raise PermissionError("held by antivirus")
real_unlink(self, missing_ok=missing_ok)
with (
patch("requests.get", return_value=_mock_response(content)),
patch("pathlib.Path.unlink", locked_unlink),
):
url = download_and_extract(
["https://example.com/lib.tar.gz"],
{},
tmp_path / "lib.archive",
tmp_path / "out",
)
assert url == "https://example.com/lib.tar.gz"
assert (tmp_path / "out" / "file.txt").read_bytes() == b"data"
assert (tmp_path / "lib.archive").exists() # left behind, harmless
def test_corrupt_archive_deleted_on_extract_failure(self, tmp_path: Path) -> None:
"""A complete-but-corrupt archive must not survive to poison the next
run; without a checksum only a failed extraction can expose it."""
with (
patch("requests.get", return_value=_mock_response(b"not an archive")),
pytest.raises(ValueError, match="Unsupported archive format"),
):
download_and_extract(
["https://example.com/lib.tar.gz"],
{},
tmp_path / "lib.archive",
tmp_path / "out",
)
assert not (tmp_path / "lib.archive").exists()
def test_importing_framework_helpers_does_not_import_requests() -> None:
"""Importing framework_helpers must not drag in requests.
@@ -1433,8 +1831,10 @@ def test_importing_framework_helpers_does_not_import_requests() -> None:
[
sys.executable,
"-c",
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))",
(
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))"
),
],
capture_output=True,
text=True,
@@ -1878,3 +2278,95 @@ class TestGetProjectCxxCompileFlags:
def test_empty_flags(self) -> None:
with patch("esphome.core.CORE", _make_core_cxx(set())):
assert get_project_cxx_compile_flags() == []
def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
"""The batch runner passes the tracker positionally; the shared adapter
must deliver it as download_with_resume's progress keyword."""
from esphome.framework_helpers import resume_fetch_job
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
fetch = resume_fetch_job("https://x/a.zip", tmp_path / "a", sha256="ff", size=9)
tracker = lambda done: None # noqa: E731
fetch(tracker)
mock_download.assert_called_once_with(
"https://x/a.zip", tmp_path / "a", progress=tracker, sha256="ff", size=9
)
def test_warn_prefetch_failures_names_each_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The shared failure loop warns per job with the failure reason."""
from esphome.framework_helpers import warn_prefetch_failures
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
assert "Could not prefetch toolchain-x@1: down" in caplog.text
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
assert "Prefetch of lib failed: gone" in caplog.text
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
# 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"),
],
)
def test_strip_win_long_path_prefix(
platform: str, input_path: str, expected: str
) -> None:
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.framework_helpers.sys.platform", platform):
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
def test_discard_partial_download_logs_undeletable(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unremovable staging file leaves a debug trace; the caller's
cache is never pruned, so silence would hide unbounded growth."""
dest = tmp_path / "archive"
dest.write_bytes(b"stale")
with (
patch.object(Path, "unlink", side_effect=OSError("busy")),
caplog.at_level(logging.DEBUG),
):
framework_helpers.discard_partial_download(dest)
assert "Could not remove" in caplog.text
def test_downloaded_bytes_reports_what_is_on_disk(tmp_path: Path) -> None:
"""Part file first, then the landed file, both capped at size; else 0."""
dest = tmp_path / "archive"
assert framework_helpers.downloaded_bytes(dest, 4) == 0
part = tmp_path / "archive.part"
part.write_bytes(b"ab")
assert framework_helpers.downloaded_bytes(dest, 4) == 2
part.write_bytes(b"abcdef")
assert framework_helpers.downloaded_bytes(dest, 4) == 4
part.unlink()
dest.write_bytes(b"abc")
assert framework_helpers.downloaded_bytes(dest, 4) == 3
assert framework_helpers.downloaded_bytes(dest) == 3
dest.write_bytes(b"abcdef")
assert framework_helpers.downloaded_bytes(dest, 4) == 4
File diff suppressed because it is too large Load Diff
+362
View File
@@ -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()
+178 -2
View File
@@ -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
+242 -5
View File
@@ -14,6 +14,8 @@ 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
@@ -30,12 +32,50 @@ HEAVY_MODULES = (
"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",)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
# 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 = (
"import sys; import esphome.__main__; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
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(
@@ -44,10 +84,207 @@ def test_main_module_does_not_import_heavy_modules() -> None:
text=True,
check=True,
)
leaked = result.stdout.strip()
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."
)
+28
View File
@@ -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
+262 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+262
View File
@@ -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()
)
+184
View File
@@ -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
+123 -6
View File
@@ -7,8 +7,10 @@ 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,
@@ -16,13 +18,15 @@ from esphome.components.nrf52.framework import (
_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
@@ -103,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,
@@ -123,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:
@@ -148,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,
@@ -201,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,
@@ -330,6 +380,7 @@ class TestSetupPlatformioPythonEnv:
(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()
@@ -374,6 +425,22 @@ class TestSetupPlatformioPythonEnv:
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,
@@ -383,6 +450,7 @@ class TestSetupPlatformioPythonEnv:
(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
@@ -404,6 +472,7 @@ class TestSetupPlatformioPythonEnv:
(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"}):
@@ -494,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 = (
@@ -506,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({})
+2 -2
View File
@@ -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: "",
}
+186
View File
@@ -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,786 @@
"""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
from filelock import Timeout
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()
def marker_appears_under_lock(*args, **kwargs):
# Simulates the concurrent build finishing while we waited
(dest / ".esphome_extracted").touch()
with (
patch("filelock.FileLock.acquire", side_effect=marker_appears_under_lock),
patch("filelock.FileLock.release"),
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_prefetch_packages_waits_with_the_holders_progress(
tmp_path: Path, held_lock
) -> None:
"""A worker parked on another build's lock reports that build's part
file, then the full size once the marker appears."""
dest = tmp_path / "a"
dest.mkdir()
ticks: list[int] = []
part = tmp_path / "dl" / "a-1.0.part"
def installed_and_pruned() -> None:
# install_package touches the marker, then unlinks the archive
(dest / ".esphome_extracted").touch()
part.unlink()
acquire = held_lock(
part,
[lambda: None, b"abc", installed_and_pruned],
(dest / ".esphome_extracted").touch,
)
def fake_batch(header, jobs):
for _name, _size, fetch in jobs:
fetch(ticks.append)
return []
with (
patch("filelock.FileLock.acquire", side_effect=acquire),
patch("filelock.FileLock.release"),
patch.object(registry, "run_batch_downloads", side_effect=fake_batch),
patch.object(registry, "download_with_resume") as mock_download,
patch.object(
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5})
),
):
registry.prefetch_packages(
[("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])],
tmp_path / "dl",
)
assert ticks == [0, 3, 10, 10]
mock_download.assert_called_once()
def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder(
tmp_path: Path,
) -> None:
"""Past the deadline the worker skips; install_package waits on the same
lock later and verifies whatever the holder produced."""
with (
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0),
patch.object(registry, "download_with_resume") as mock_download,
patch.object(
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5})
),
):
registry.prefetch_packages(
[("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])],
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

Some files were not shown because too many files have changed in this diff Show More