Merge branch 'dev' into store-yaml-firmware

This commit is contained in:
J. Nick Koston
2026-07-03 19:04:12 -05:00
committed by GitHub
1326 changed files with 27006 additions and 16857 deletions
+98
View File
@@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property(
assert "JPEGDEC APPEND" not in content
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()
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content
def test_get_component_cmakelists_single_link_flag() -> None:
"""A single -Wl, flag appears indented inside target_link_options."""
CORE.build_flags = {"-Wl,--gc-sections"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert (
"target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)"
in content
)
def test_get_component_cmakelists_multiple_link_flags_sorted() -> None:
"""Multiple -Wl, flags are sorted and joined with the four-space indent."""
CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
expected = (
"target_link_options(${COMPONENT_LIB} PUBLIC\n"
" -Wl,--gc-sections\n"
" -Wl,-Map=out.map\n"
" -Wl,-z,noexecstack\n"
")"
)
assert expected in content
def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None:
"""-D and -W (non-linker) flags must not appear in target_link_options."""
CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert "-DFOO" not in content.split("target_link_options")[1]
assert "-Wall" not in content.split("target_link_options")[1]
assert "-Wl,--gc-sections" in content
def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path,
) -> None:
@@ -162,3 +210,53 @@ def test_get_project_cmakelists_emits_managed_components_property(
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
" espressif__esp-dsp APPEND)"
) in content
def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None:
"""cg.set_cpp_standard() replaces the IDF default -std in
CXX_COMPILE_OPTIONS between include(project.cmake) and project()."""
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
patch.object(CORE, "cpp_standard", "gnu++20"),
):
from esphome.build_gen.espidf import get_project_cmakelists
content = get_project_cmakelists(minimal=True)
assert (
"idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)"
in content
)
assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content
assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content
# The replacement must come after project.cmake (which appends the IDF
# default) and before project() (which consumes the options).
include_pos = content.index("tools/cmake/project.cmake")
replace_pos = content.index("CXX_COMPILE_OPTIONS")
project_pos = content.index("project(test)")
assert include_pos < replace_pos < project_pos
def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None:
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
patch.object(CORE, "cpp_standard", None),
):
from esphome.build_gen.espidf import get_project_cmakelists
content = get_project_cmakelists(minimal=True)
assert "CXX_COMPILE_OPTIONS" not in content
def test_get_component_cmakelists_no_compile_features() -> None:
"""The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the
top-level CMakeLists; the src component must not set its own."""
with patch.object(CORE, "build_flags", set()):
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert "target_compile_features" not in content
@@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same(
call_args = mock_write_file_if_changed.call_args[0]
assert call_args[0] == ini_file
assert content in call_args[1]
@pytest.fixture
def clean_core(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(CORE, "name", "test")
monkeypatch.setattr(CORE, "platformio_options", {})
monkeypatch.setattr(CORE, "platformio_libraries", {})
monkeypatch.setattr(CORE, "build_flags", set())
monkeypatch.setattr(CORE, "build_unflags", set())
def test_get_ini_content_pins_cpp_standard(
clean_core: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""cg.set_cpp_standard() pins -std via build_flags and unflags every other
known standard so the platform/framework default is stripped."""
monkeypatch.setattr(CORE, "cpp_standard", "gnu++20")
content = platformio.get_ini_content()
flags_section = content.split("build_flags =")[1].split("build_unflags =")[0]
unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0]
assert "-std=gnu++20\n" in flags_section
# Both the GNU and strict dialects of every other standard are stripped.
for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"):
assert f"-std=gnu++{year}\n" in unflags_section
assert f"-std=c++{year}\n" in unflags_section
assert "-std=c++20\n" in unflags_section
# The selected standard must not unflag itself.
assert "-std=gnu++20\n" not in unflags_section
def test_get_ini_content_no_cpp_standard(
clean_core: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(CORE, "cpp_standard", None)
content = platformio.get_ini_content()
assert "-std=" not in content
@@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace(
assert state is False
def test_process_stacktrace_esp8266_crash_handler(
setup_core: Path, mock_esp8266_decode_pc: Mock
) -> None:
"""Test process_stacktrace handles ESP8266 crash handler backtrace lines."""
from esphome.components.esp8266 import process_stacktrace
config = {"name": "test"}
# Simulate crash handler log lines as they appear from the API/serial
line_pc = "[E][esp8266:191]: PC: 0x40220060"
state = process_stacktrace(config, line_pc, False)
mock_esp8266_decode_pc.assert_called_once_with(config, "40220060")
assert state is False
mock_esp8266_decode_pc.reset_mock()
# Near-null data address (wild pointer) is not a code address, must be ignored
line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A"
state = process_stacktrace(config, line_excvaddr, False)
mock_esp8266_decode_pc.assert_not_called()
assert state is False
mock_esp8266_decode_pc.reset_mock()
line_bt0 = "[E][esp8266:196]: BT0: 0x40212345"
state = process_stacktrace(config, line_bt0, False)
mock_esp8266_decode_pc.assert_called_once_with(config, "40212345")
assert state is False
def test_process_stacktrace_esp32_backtrace(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
+42
View File
@@ -1,6 +1,8 @@
"""Tests for time component cron expression parsing."""
import errno
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non
_mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")),
):
assert validate_tz("<+08>-8") == "<+08>-8"
def _modules_after(code: str) -> set[str]:
"""Run code in a fresh interpreter and return the imported module names.
A subprocess is required because the test process itself has already
imported aioesphomeapi via other tests, so sys.modules here is useless.
"""
result = subprocess.run(
[sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"],
capture_output=True,
text=True,
check=True,
)
return set(result.stdout.split())
def test_importing_time_does_not_import_aioesphomeapi() -> None:
"""Importing the time component must not drag in aioesphomeapi.
aioesphomeapi is a heavy import (it builds a large number of dataclasses at
import time). The time component is auto-loaded by many components, so
importing it for its schema during config validation must not pay that
cost. The import is deferred to the functions that actually need it.
"""
modules = _modules_after("import esphome.components.time")
assert "aioesphomeapi" not in modules
def test_validate_tz_imports_aioesphomeapi_lazily() -> None:
"""Validating a non-empty timezone is what triggers the lazy import.
Documents the boundary: the cost is only paid when a timezone is actually
validated, not merely by loading the component.
"""
modules = _modules_after(
"from esphome.components.time import validate_tz\n"
"validate_tz('EST5EDT,M3.2.0,M11.1.0')"
)
assert "aioesphomeapi" in modules
+149 -3
View File
@@ -20,6 +20,9 @@ from esphome.const import (
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, config
from esphome.core.config import (
@@ -149,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
("multiple_areas_devices.yaml", "Main Area"),
],
)
async def test_to_code_records_core_area(
async def test_core_area_recorded_at_config_load(
yaml_file: Callable[[str], Path],
fixture: str,
expected_area: str,
) -> None:
"""``to_code`` records the node's area name on CORE for StorageJSON."""
"""The node's area name is recorded on CORE for StorageJSON.
It must be set during config load (preload_core_config), not deferred to
to_code(): storage.json is written before to_code() runs, so a late
assignment left the area as null in storage.json (regression #17218).
"""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is not None
assert CORE.area is None
# Recorded already at config-load time, before any code generation.
assert CORE.area == expected_area
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
@@ -167,6 +176,23 @@ async def test_to_code_records_core_area(
assert CORE.area == expected_area
def test_config_load_without_area_clears_stale_core_area(
yaml_file: Callable[[str], Path],
) -> None:
"""A config without an area must not inherit a stale CORE.area.
preload_core_config assigns CORE.area unconditionally, so the area from a
previous load in a long-running process cannot leak into a config that
omits it.
"""
CORE.area = "Stale Area From Previous Load"
result = load_config_from_fixture(
yaml_file, "device_without_area.yaml", FIXTURES_DIR
)
assert result is not None
assert CORE.area is None
def test_legacy_string_area(
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
) -> None:
@@ -1161,3 +1187,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None:
cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False)
# cpp_string_escape uses octal escapes for quotes
assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes
@pytest.mark.parametrize(
("lib", "name", "version", "repository"),
[
("ArduinoJson", "ArduinoJson", None, None),
("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None),
(
"noise-c=https://github.com/esphome/noise-c.git",
"noise-c",
None,
"https://github.com/esphome/noise-c.git",
),
],
)
def test_add_library_str(
lib: str, name: str, version: str | None, repository: str | None
) -> None:
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
config._add_library_str(lib)
libraries = list(CORE.platformio_libraries.values())
assert len(libraries) == 1
assert libraries[0].name == name
assert libraries[0].version == version
assert libraries[0].repository == repository
@pytest.mark.asyncio
async def test_add_platformio_options_native_idf(
caplog: pytest.LogCaptureFixture,
) -> None:
"""On the native IDF toolchain, build_flags/lib_deps/lib_ignore are
honored, upload_speed is silent and everything else warns."""
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
"lib_ignore": "libsodium",
"upload_speed": "115200",
"board_build.f_flash": "80000000L",
}
)
assert "-DSINGLE_FLAG" in CORE.build_flags
assert "ArduinoJson" in CORE.platformio_libraries
# 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"]}
assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text
assert "upload_speed" not in caplog.text
# build_flags has a first-class esphome equivalent, so it is deprecated.
# lib_deps/lib_ignore are kept as valid platformio_options (no warning).
assert (
"esphome->platformio_options->build_flags is deprecated; use "
"esphome->build_flags instead" in caplog.text
)
assert "lib_deps is deprecated" not in caplog.text
assert "lib_ignore is deprecated" not in caplog.text
@pytest.mark.asyncio
async def test_add_platformio_options_platformio(
caplog: pytest.LogCaptureFixture,
) -> None:
"""On the PlatformIO toolchain all options pass through to the ini,
with build_flags/lib_ignore listified."""
CORE.toolchain = Toolchain.PLATFORMIO
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG",
"lib_ignore": "libsodium",
"upload_speed": "115200",
}
)
assert CORE.platformio_options == {
"build_flags": ["-DSINGLE_FLAG"],
"lib_ignore": ["libsodium"],
"upload_speed": "115200",
}
# platformio_options is the correct mechanism on the PlatformIO toolchain,
# so the native-equivalent deprecation must not fire here.
assert "deprecated" not in caplog.text
def test_add_library_str_bare_url_requires_name() -> None:
"""A bare repository URL has no library name; CORE.add_library rejects it."""
with pytest.raises(ValueError, match="must have a name"):
config._add_library_str("https://github.com/esphome/noise-c.git")
@pytest.mark.asyncio
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None:
"""esphome->libraries entries are parsed and registered via cg.add_library."""
result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR)
assert result is not None
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
mock_cg.add_library.assert_any_call("SomeLib", None)
mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2")
mock_cg.add_library.assert_any_call(
"noise-c", None, "https://github.com/esphome/noise-c.git"
)
+5 -4
View File
@@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None:
def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix.
In C++, when name_add_mac_suffix is enabled and entity has no name,
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name()))
dynamically. Our function always returns the same result since we're
calculating the base for duplicate tracking.
In C++, an entity's object_id is computed from its name_ via
write_object_id_to() (sanitized snake_case). When an entity has no name,
configure_entity_() sets name_ from the friendly name, with the MAC suffix
appended when name_add_mac_suffix is enabled. Our function always returns
the same result since we're calculating the base for duplicate tracking.
"""
# The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id
@@ -0,0 +1,8 @@
esphome:
name: test-libraries
libraries:
- SomeLib
- bblanchon/ArduinoJson@7.4.2
- noise-c=https://github.com/esphome/noise-c.git
host:
@@ -1,6 +1,7 @@
"""Unit tests for esphome.config module."""
from collections.abc import Generator
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
@@ -113,3 +114,57 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
platforms = {p.get("platform") for p in result["ota"]}
assert "esphome" in platforms, f"Expected esphome platform in {platforms}"
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.
The second `logger:` is dropped by the shallow merge. Returns the main file.
"""
(tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n")
(tmp_path / "b.yaml").write_text("logger:\n level: INFO\n")
esphome_section = "esphome:\n name: test\n"
if suppress:
esphome_section += " merge_warnings: false\n"
main = tmp_path / "main.yaml"
main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n")
return main
def test_validate_config_warns_on_dropped_merge_key(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""By default, a `<<` merge that drops a key logs a warning."""
main = _write_merge_conflict_config(tmp_path, suppress=False)
CORE.config_path = main
raw_config = yaml_util.load_yaml(main)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
config.validate_config(raw_config, {})
assert any(
"was dropped while processing a '<<' merge" in record.message
and "logger" in record.message
for record in caplog.records
)
# The queue is drained so the warning cannot leak into a later run.
assert yaml_util.take_dropped_merge_keys() == []
def test_validate_config_suppresses_merge_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""`esphome: merge_warnings: false` hides the warning but still drains the queue."""
main = _write_merge_conflict_config(tmp_path, suppress=True)
CORE.config_path = main
raw_config = yaml_util.load_yaml(main)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
config.validate_config(raw_config, {})
assert not any(
"was dropped while processing a '<<' merge" in record.message
for record in caplog.records
)
# The queue is drained even when the warning is suppressed.
assert yaml_util.take_dropped_merge_keys() == []
File diff suppressed because it is too large Load Diff
+18
View File
@@ -915,3 +915,21 @@ class TestEsphomeCore:
mock_enable.assert_called_once_with("Wire")
assert "Wire" in target.platformio_libraries
def test_add_build_unflag__warns_on_native_idf_toolchain(
self, target, caplog: pytest.LogCaptureFixture
) -> None:
"""Build unflags are not consumed by the native IDF build generator,
so adding one on that toolchain warns; PlatformIO stays silent."""
target.toolchain = const.Toolchain.PLATFORMIO
target.add_build_unflag("-fno-rtti")
assert "ignored" not in caplog.text
target.toolchain = const.Toolchain.ESP_IDF
target.add_build_unflag("-fno-exceptions")
assert (
"Build unflag -fno-exceptions is ignored when building with the "
"native ESP-IDF toolchain" in caplog.text
)
# The unflag is still recorded either way.
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
+3 -3
View File
@@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env(
target_framework: str,
expected: str,
) -> None:
"""_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps."""
"""_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps."""
# monkeypatch snapshots os.environ, so the env var _setup_core writes is
# restored after the test instead of leaking into later tests.
monkeypatch.delenv("ESPHOME_ARDUINO", raising=False)
monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False)
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
assert os.environ["ESPHOME_ARDUINO"] == expected
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
+153 -64
View File
@@ -1,3 +1,4 @@
import hashlib
import json
import os
from pathlib import Path
@@ -13,23 +14,23 @@ from esphome.const import (
Platform,
)
from esphome.core import CORE, Library
import esphome.espidf.component
from esphome.espidf.component import (
generate_cmakelists_txt,
generate_idf_component_yml,
generate_idf_components,
)
import esphome.platformio.library
from esphome.platformio.library import (
ConvertedLibrary as IDFComponent,
GitSource,
IDFComponent,
InvalidIDFComponent,
URLSource,
_check_library_data,
_collect_filtered_files,
_node_key,
_normalize_dependencies,
_parse_library_json,
_parse_library_properties,
_resolve_registry_version,
_split_list_by_condition,
generate_cmakelists_txt,
generate_idf_component_yml,
generate_idf_components,
collect_filtered_files,
split_list_by_condition,
)
@@ -69,7 +70,7 @@ def test_collect_filtered_files_basic(tmp_path):
f2.parent.mkdir(parents=True)
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*>"])
result = collect_filtered_files(tmp_path, ["+<*>"])
assert str(f1) in result
assert str(f2) in result
@@ -80,7 +81,7 @@ def test_collect_filtered_files_exclude(tmp_path):
f1.write_text("int a;")
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
result = collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
assert str(f1) in result
assert str(f2) not in result
@@ -88,7 +89,7 @@ def test_collect_filtered_files_exclude(tmp_path):
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
matched, rest = _split_list_by_condition(
matched, rest = split_list_by_condition(
items, lambda x: x[2:] if x.startswith("-I") else None
)
@@ -201,41 +202,6 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component):
generate_idf_component_yml(tmp_component)
def test_check_library_data_valid(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "*"})
def test_check_library_data_valid2(esp32_idf_core):
_check_library_data({"platforms": "*"})
def test_check_library_data_valid3(esp32_idf_core):
_check_library_data({})
def test_check_library_data_valid4(esp32_idf_core):
_check_library_data({"platforms": "espressif32", "frameworks": "*"})
def test_check_library_data_valid5(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "espidf"})
def test_check_library_data_invalid_platform(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": ["other"], "frameworks": "*"})
def test_check_library_data_invalid_framework(
esp32_idf_core: None, caplog: pytest.LogCaptureFixture
) -> None:
# Framework mismatch is a warning, not a hard skip: the library is still
# included so that PIO manifests that only list "arduino" (but actually
# compile under IDF) can be used without forking them.
_check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]})
assert "do not include 'espidf'" in caplog.text
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
@@ -452,7 +418,7 @@ def _patch_registry(monkeypatch, versions):
``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on
the canned data so the intersection logic is exercised for real.
"""
registry = esphome.espidf.component._make_registry_client()
registry = esphome.platformio.library._make_registry_client()
monkeypatch.setattr(
registry,
"fetch_registry_package",
@@ -466,7 +432,7 @@ def _patch_registry(monkeypatch, versions):
},
)
monkeypatch.setattr(
esphome.espidf.component, "_make_registry_client", lambda: registry
esphome.platformio.library, "_make_registry_client", lambda: registry
)
@@ -515,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
"esphome/C": {"name": "C"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -534,7 +500,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
monkeypatch.setattr(
esphome.espidf.component, "_resolve_registry_version", fake_resolve
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
top = generate_idf_components(
@@ -557,6 +523,62 @@ def test_generate_idf_components_dedupes_shared_dependency(
assert "idf_component_register" in generated
def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# lib_ignore must drop B at the top level and C when it is discovered as a
# dependency of A during the graph walk -- neither may be resolved,
# downloaded, or wired into a manifest. Matching is by lowercase short name.
manifests = {
"esphome/A": {
"name": "A",
"dependencies": [
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
],
},
"esphome/B": {"name": "B"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
# lib_ignore is read from CORE.platformio_options (stored there by
# _add_platformio_options); matched by lowercase short name.
monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]})
top = generate_idf_components(
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
)
assert [c.name for c in top] == ["esphome/A"]
# Ignored libraries were never resolved (and therefore never downloaded).
assert resolve_calls == ["A"]
# The ignored dependency is not wired into A's manifest.
assert top[0].dependencies == []
# lib_ignore changes the generated wiring, so the cache path is salted to
# keep this conversion separate from ones with a different lib_ignore.
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_handles_dependency_cycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -575,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle(
},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -583,7 +605,7 @@ def test_generate_idf_components_handles_dependency_cycle(
monkeypatch.setattr(IDFComponent, "download", fake_download)
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
@@ -632,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
"esphome/shared": {"name": "shared"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -640,7 +662,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
monkeypatch.setattr(IDFComponent, "download", fake_download)
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
@@ -669,14 +691,14 @@ def test_generate_idf_components_missing_manifest_raises(
) -> None:
# A library with neither library.json nor library.properties is invalid;
# fail loudly rather than silently generating build files for it.
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
# no library.json / library.properties written
monkeypatch.setattr(IDFComponent, "download", fake_download)
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
@@ -711,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
"owner/shared": {"name": "shared"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -720,7 +742,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
monkeypatch.setattr(IDFComponent, "download", fake_download)
# Bare "shared" and "owner/shared" both resolve to canonical owner/shared.
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner or "owner",
@@ -744,7 +766,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
) -> None:
# A top-level library that isn't ESP-IDF/esp32 compatible must fail fast,
# not be silently dropped.
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(
@@ -753,7 +775,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
monkeypatch.setattr(IDFComponent, "download", fake_download)
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
@@ -763,7 +785,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
),
)
with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"):
with pytest.raises(RuntimeError, match="not compatible with espidf"):
generate_idf_components([Library("esphome/A", "1.0.0", None)])
@@ -782,14 +804,14 @@ def test_generate_idf_components_incompatible_dependency_skipped(
"esphome/B": {"name": "B", "platforms": ["espressif8266"]},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
monkeypatch.setattr(
esphome.espidf.component,
esphome.platformio.library,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
@@ -804,3 +826,70 @@ def test_generate_idf_components_incompatible_dependency_skipped(
assert [c.name for c in top] == ["esphome/A"]
# The incompatible dependency was dropped, not wired in.
assert top[0].dependencies == []
def test_url_source_salt_changes_cache_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The salt is mixed into the URL hash so salted conversions get their own
cache tree. Pre-created extraction markers keep this network-free."""
monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml")
url = "http://example.com/lib.tar.gz"
base = tmp_path / ".esphome" / "pio_components"
expected = {}
for salt in ("", "abcd1234"):
digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8]
expected[salt] = base / digest / "lib"
expected[salt].mkdir(parents=True)
(expected[salt] / ".esphome_extracted").touch()
source = URLSource(url)
assert source.download("lib") == expected[""]
assert source.download("lib", salt="abcd1234") == expected["abcd1234"]
# A backend namespace adds a pio_components/<namespace>/ subdir.
digest = hashlib.sha256(url.encode()).hexdigest()[:8]
ns_expected = base / "idf" / digest / "lib"
ns_expected.mkdir(parents=True)
(ns_expected / ".esphome_extracted").touch()
assert source.download("lib", namespace="idf") == ns_expected
def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
"""The salt becomes a subdirectory of the git clone domain."""
domains: list[str] = []
def fake_clone_or_update(**kwargs):
domains.append(kwargs["domain"])
return Path("/cloned"), None
monkeypatch.setattr(
esphome.platformio.library.git, "clone_or_update", fake_clone_or_update
)
source = GitSource("https://github.com/esphome/noise-c.git", "v1.0")
source.download("noise-c")
source.download("noise-c", salt="abcd1234")
source.download("noise-c", namespace="idf")
source.download("noise-c", namespace="zephyr", salt="abcd1234")
assert domains == [
"pio_components",
"pio_components/abcd1234",
"pio_components/idf",
"pio_components/zephyr/abcd1234",
]
def test_idf_component_download_passes_salt() -> None:
"""IDFComponent.download forwards the sanitized name and salt to the
source and records the returned path."""
source = MagicMock()
source.download.return_value = Path("/converted/owner/name")
c = IDFComponent("owner/name", "1.0", source=source)
c.download(force=True, salt="abcd1234", namespace="idf")
source.download.assert_called_once_with(
"owner/name", force=True, salt="abcd1234", namespace="idf"
)
assert c.path == Path("/converted/owner/name")
+168 -13
View File
@@ -15,12 +15,12 @@ from unittest.mock import patch
import pytest
from esphome.espidf.framework import (
_ccache_env,
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
_get_framework_path,
_get_idf_tool_paths,
_get_idf_tools_path,
_get_idf_version,
_get_python_env_path,
_get_python_version,
@@ -31,10 +31,24 @@ from esphome.espidf.framework import (
_write_stamp,
check_esp_idf_install,
get_framework_env,
get_idf_tools_path,
)
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@pytest.fixture(autouse=True)
def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the ESP-IDF install root to a tmp dir for every test.
The default location is the OS user cache dir, so without this any test
that builds framework paths or pre-creates the framework dir would touch
the real ``~/.cache/esphome`` on the developer's machine. Tests that need
to exercise the override or default-resolution logic clear/override the env
themselves.
"""
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install"))
@pytest.mark.parametrize(
("source", "expected"),
[
@@ -64,6 +78,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl
"https://github.com/espressif/esp-idf.git@v6.0.1",
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
),
# '#' ref separator (PlatformIO/git-web convention) works on both forms
(
"https://github.com/espressif/esp-idf.git#release/v6.1",
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
),
(
"github://espressif/esp-idf#release/v6.1",
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
),
(
"github://espressif/esp-idf.git#master",
("https://github.com/espressif/esp-idf.git", "master"),
),
# Tolerate a trailing ".git" on the shorthand so the user doesn't
# silently end up with a doubled "...esp-idf.git.git" URL.
(
@@ -298,6 +325,9 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework.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(
"esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})
) as tool_paths,
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"),
@@ -308,7 +338,12 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
yield SimpleNamespace(
download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone
download=download,
extract=extract,
venv=venv,
run_ok=run_ok,
tool_paths=tool_paths,
clone=clone,
)
@@ -403,10 +438,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls(
def test_check_esp_idf_install_check_command_failure_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A failing idf_tools check reinstalls tools (marker present, no re-extract)."""
"""A failing tool-path resolution reinstalls tools (marker present, no re-extract)."""
_mark_installed()
# idf_tools check fails -> install stays True; the later installs succeed.
espidf_mocks.run_ok.side_effect = [False, True, True, True]
# Managed tool resolution fails -> install stays True; the later installs succeed.
espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool")
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
@@ -604,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None:
def test_get_framework_env_with_python_env(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"),
@@ -612,6 +647,8 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None:
"esphome.espidf.framework._get_idf_tool_paths",
return_value=(["/tool/bin"], {"IDF_X": "1"}),
),
# ccache env is covered separately; keep this test host-independent.
patch("esphome.espidf.framework._ccache_env", return_value={}),
):
env = get_framework_env(
tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"}
@@ -627,11 +664,13 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None:
def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"),
patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})),
# ccache env is covered separately; keep this test host-independent.
patch("esphome.espidf.framework._ccache_env", return_value={}),
):
env = get_framework_env(tmp_path / "fw")
@@ -640,7 +679,89 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No
# ---------------------------------------------------------------------------
# _check_stamp / _write_idf_version_txt / _get_idf_tools_path
# _ccache_env
# ---------------------------------------------------------------------------
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.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch(
"esphome.espidf.framework.CORE",
SimpleNamespace(build_path=build_path),
),
)
def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
with patch.dict("os.environ", {}, clear=True), p1, p2, p3:
env = _ccache_env()
assert env["IDF_CCACHE_ENABLE"] == "1"
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
assert env["CCACHE_NOHASHDIR"] == "true"
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
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() == {}
def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
# Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and
# 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() == {}
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.
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["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
assert env["CCACHE_DEPEND"] == "1"
def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None:
# User-set CCACHE_* values must not be clobbered; unset ones still default.
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"}
with patch.dict("os.environ", user_env, clear=True), p1, p2, p3:
env = _ccache_env()
assert "CCACHE_DIR" not in env
assert "CCACHE_MAXSIZE" not in env
assert env["IDF_CCACHE_ENABLE"] == "1"
assert env["CCACHE_DEPEND"] == "1"
def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None:
# Enabled but no build_path means the IDF env was built too early -- fail
# loudly instead of silently dropping CCACHE_BASEDIR.
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None)
with (
patch.dict("os.environ", {}, clear=True),
p1,
p2,
p3,
pytest.raises(ValueError, match="build_path"),
):
_ccache_env()
# ---------------------------------------------------------------------------
# _check_stamp / _write_idf_version_txt / get_idf_tools_path
# ---------------------------------------------------------------------------
@@ -677,10 +798,42 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None:
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n"
def test_get_idf_tools_path_env_override(tmp_path: Path) -> None:
def testget_idf_tools_path_env_override(tmp_path: Path) -> None:
override = str(tmp_path / "custom-idf")
with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}):
assert _get_idf_tools_path() == Path(override)
assert get_idf_tools_path() == Path(override)
@pytest.mark.parametrize("value", ["", " "])
def testget_idf_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD.
Path("") would resolve to the working directory, which clean-all could then
delete by accident.
"""
import platformdirs
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert get_idf_tools_path() == expected
def testget_idf_tools_path_default_uses_user_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without the env override the install root is the machine-global OS user
cache dir, not the per-config ``<data_dir>/idf``."""
import platformdirs
monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert get_idf_tools_path() == expected
def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None:
@@ -755,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled(
patch(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=True
),
patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock,
patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock,
caplog.at_level(logging.WARNING),
):
_check_windows_path_length()
@@ -772,7 +925,7 @@ def test_check_windows_path_length_short_path_silent(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=False
),
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=_SHORT_IDF_PATH,
),
caplog.at_level(logging.WARNING),
@@ -790,7 +943,7 @@ def test_check_windows_path_length_long_path_warns(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=False
),
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=_LONG_IDF_PATH,
),
caplog.at_level(logging.WARNING),
@@ -800,3 +953,5 @@ def test_check_windows_path_length_long_path_warns(
message = caplog.records[0].getMessage()
assert _LONG_IDF_PATH in message
assert "long path support" in message
# The install is global now; the remedy is the prefix env, not moving the project.
assert "ESPHOME_ESP_IDF_PREFIX" in message
+38 -4
View File
@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "g++"}
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
prog_path = str(toolchain.get_elf_path())
assert result == {"cxx_path": "g++", "prog_path": prog_path}
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:
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh"}
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
@@ -147,7 +148,40 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen"}
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."""
compile_commands, _ = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
# Use Path semantics so the contract holds on Windows too (backslashes).
prog_path = Path(result["prog_path"])
assert prog_path.name == "firmware.elf"
assert prog_path.parent.name == "build"
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
"""The IDF env caps git's upward search at the config directory.
This stops ESP-IDF's `git describe` from walking into an uninitialized or
corrupt git repo in a parent directory and failing the build.
"""
toolchain._cache().env.clear()
# Set IDF_PATH so the framework-install branch is skipped.
with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}):
env = toolchain._get_idf_env(version="5.5.4")
assert CORE.config_dir == setup_core
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_core_framework_version_from_core_data():
+113 -17
View File
@@ -25,6 +25,8 @@ from esphome.framework_helpers import (
archive_extract_all,
create_venv,
download_from_mirrors,
get_project_compile_flags,
get_project_link_flags,
get_python_env_executable_path,
get_system_python_path,
rmdir,
@@ -524,7 +526,7 @@ class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
target = tmp_path / "out.bin"
with patch(
"esphome.framework_helpers.requests.get",
"requests.get",
return_value=_mock_response(b"filedata"),
):
url = download_from_mirrors(["https://example.com/f"], {}, target)
@@ -533,7 +535,7 @@ class TestDownloadFromMirrors:
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
with patch(
"esphome.framework_helpers.requests.get",
"requests.get",
return_value=_mock_response(b"x"),
) as mock_get:
download_from_mirrors(
@@ -545,7 +547,7 @@ class TestDownloadFromMirrors:
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
with patch(
"esphome.framework_helpers.requests.get",
"requests.get",
side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")],
):
url = download_from_mirrors(
@@ -559,7 +561,7 @@ class TestDownloadFromMirrors:
def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None:
with (
patch(
"esphome.framework_helpers.requests.get",
"requests.get",
return_value=_mock_response(b"", ok=False),
),
pytest.raises(req.HTTPError),
@@ -577,7 +579,7 @@ class TestDownloadFromMirrors:
def test_file_like_target_written(self) -> None:
buf = io.BytesIO()
with patch(
"esphome.framework_helpers.requests.get",
"requests.get",
return_value=_mock_response(b"bytes"),
):
download_from_mirrors(["https://example.com/f"], {}, buf)
@@ -588,7 +590,7 @@ class TestDownloadFromMirrors:
r = _mock_response(b"1234567890")
r.headers = {"content-length": "10"}
with (
patch("esphome.framework_helpers.requests.get", return_value=r),
patch("requests.get", return_value=r),
patch("esphome.framework_helpers.ProgressBar") as mock_pb,
):
download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin")
@@ -604,12 +606,35 @@ class TestDownloadFromMirrors:
r.headers = {"content-length": "0"}
r.iter_content.return_value = [b""] # one empty chunk
target = tmp_path / "out.bin"
with patch("esphome.framework_helpers.requests.get", return_value=r):
with patch("requests.get", return_value=r):
download_from_mirrors(["https://example.com/f"], {}, target)
assert target.exists()
assert target.read_bytes() == b""
def test_importing_framework_helpers_does_not_import_requests() -> None:
"""Importing framework_helpers must not drag in requests.
requests is a heavy import (~85ms) only needed by download_from_mirrors to
fetch toolchains during a build. framework_helpers is loaded during config
validation (esp-idf framework, host platform), so the import is deferred to
the function that uses it. A fresh interpreter is required because the test
process has already imported requests.
"""
result = subprocess.run(
[
sys.executable,
"-c",
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))",
],
capture_output=True,
text=True,
check=True,
)
assert "requests" not in result.stdout.split()
# ---------------------------------------------------------------------------
# get_python_env_executable_path — Windows branch
# ---------------------------------------------------------------------------
@@ -633,11 +658,6 @@ def test_get_python_env_executable_path_nt() -> None:
class TestTarExtractAllBranches:
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason="patching os.name makes pathlib build a WindowsPath, which only "
"instantiates on POSIX in 3.12+",
)
def test_windows_drive_path_skipped(self, tmp_path: Path) -> None:
"""Windows-style drive path (C:/...) is skipped when os.name == 'nt'."""
info = tarfile.TarInfo(name="C:/secret.txt")
@@ -730,11 +750,6 @@ class TestTarExtractAllBranches:
class TestZipExtractAllBranches:
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason="patching os.name makes pathlib build a WindowsPath, which only "
"instantiates on POSIX in 3.12+",
)
def test_windows_drive_path_skipped(self, tmp_path: Path) -> None:
"""Windows-style drive path (C:/...) is skipped when os.name == 'nt'."""
buf = _make_zip([("C:/secret.txt", "bad")])
@@ -952,3 +967,84 @@ class TestSevenZipExtractAll:
out.mkdir()
archive_extract_all(archive, out)
assert (out / "hello.txt").exists()
# ---------------------------------------------------------------------------
# get_project_compile_flags / get_project_link_flags
# ---------------------------------------------------------------------------
def _make_core(flags: set[str]):
core = MagicMock()
core.build_flags = flags
return core
class TestGetProjectCompileFlags:
def test_returns_define_flags(self) -> None:
with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})):
assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"]
def test_returns_warning_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wno-error", "-Wall"}),
):
assert get_project_compile_flags() == ["-Wall", "-Wno-error"]
def test_excludes_linker_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}),
):
assert get_project_compile_flags() == ["-DFOO"]
def test_excludes_other_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-O2", "-std=gnu++20", "-DFOO"}),
):
assert get_project_compile_flags() == ["-DFOO"]
def test_empty_build_flags(self) -> None:
with patch("esphome.core.CORE", _make_core(set())):
assert get_project_compile_flags() == []
def test_result_is_sorted(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}),
):
result = get_project_compile_flags()
assert result == sorted(result)
class TestGetProjectLinkFlags:
def test_returns_linker_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}),
):
assert get_project_link_flags() == [
"-Wl,--gc-sections",
"-Wl,-Map=output.map",
]
def test_excludes_compile_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}),
):
assert get_project_link_flags() == ["-Wl,--gc-sections"]
def test_empty_build_flags(self) -> None:
with patch("esphome.core.CORE", _make_core(set())):
assert get_project_link_flags() == []
def test_result_is_sorted(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}),
):
result = get_project_link_flags()
assert result == sorted(result)
+27 -16
View File
@@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected):
assert helpers.friendly_name_slugify(value) == expected
def test_friendly_name_slugify_back_compat_shim():
"""``esphome.dashboard.util.text`` keeps re-exporting for back-compat.
The function moved to ``esphome.helpers`` so the new
device-builder dashboard backend can import it without depending
on the legacy dashboard package, but downstream code that still
imports from the old path keeps working until the dashboard
module is removed.
"""
from esphome.dashboard.util.text import (
friendly_name_slugify as legacy_friendly_name_slugify,
)
assert legacy_friendly_name_slugify is helpers.friendly_name_slugify
@pytest.mark.parametrize(
"host",
(
@@ -196,6 +180,33 @@ def test_is_ha_addon(monkeypatch, value, expected):
assert actual == expected
def test_add_git_ceiling_directory_sets_when_unset():
"""An empty env gets GIT_CEILING_DIRECTORIES set to the directory."""
env: dict[str, str] = {}
directory = Path("/home/user/config")
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
def test_add_git_ceiling_directory_appends_to_existing():
"""An existing value is preserved and the new directory is appended."""
env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))}
directory = Path("/home/user/config")
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [
str(Path("/some/ceiling")),
str(directory),
]
def test_add_git_ceiling_directory_skips_duplicate():
"""A directory already in the list is not appended again."""
directory = Path("/home/user/config")
env = {"GIT_CEILING_DIRECTORIES": str(directory)}
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
def test_walk_files(fixture_path):
path = fixture_path / "helpers"
+661 -2
View File
@@ -1,8 +1,28 @@
"""Unit tests for esphome.loader module."""
from unittest.mock import MagicMock, patch
import ast
import logging
from pathlib import Path
import sys
import textwrap
from types import ModuleType
from unittest.mock import MagicMock, Mock, patch
from esphome.loader import ComponentManifest, _replace_component_manifest, get_component
import pytest
import voluptuous as vol
from esphome import config as esphome_config, config_validation as cv
from esphome.core import CORE
import esphome.loader as loader_mod
from esphome.loader import (
AliasMeta,
ComponentManifest,
_AliasFinder,
_build_alias_map,
_read_aliases,
_replace_component_manifest,
get_component,
)
from tests.testing_helpers import ComponentManifestOverride
# ---------------------------------------------------------------------------
@@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub
names = [r.resource for r in manifest.resources]
assert names == ["wake/wake_freertos.cpp"]
# ---------------------------------------------------------------------------
# Component aliases (renamed-platform back-compat)
# ---------------------------------------------------------------------------
#
# These tests pin down the substrate behind `ALIASES = [...]` on component
# `__init__.py` files: the AST scanner, the resulting global alias map, the
# Python-import `sys.meta_path` finder, the `get_component` integration, and
# the YAML pre-pass that rewrites legacy top-level keys.
#
# The framework is component-agnostic, so the integration tests inject a
# synthetic alias map (pointing a fake legacy name at the real `esp32`
# component) rather than depending on any specific renamed component.
# A legacy name that is NOT a real component, used as a synthetic alias.
_FAKE_ALIAS = "esp32_legacy_alias"
def _write_component(root: Path, name: str, body: str) -> None:
"""Write a fake component package at ``root/<name>/__init__.py``."""
pkg = root / name
pkg.mkdir()
(pkg / "__init__.py").write_text(body)
def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None:
"""AST scan should pick up ``ALIASES = ["legacy"]`` without executing."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['legacy_name']\n")
aliases, removal = _read_aliases(init, ast)
assert aliases == ["legacy_name"]
assert removal is None
def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None:
"""``ALIAS_REMOVAL_VERSION`` should be paired with the alias list."""
init = tmp_path / "__init__.py"
init.write_text(
textwrap.dedent("""\
ALIASES = ['old']
ALIAS_REMOVAL_VERSION = "2027.6.0"
""")
)
aliases, removal = _read_aliases(init, ast)
assert aliases == ["old"]
assert removal == "2027.6.0"
def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None:
"""A call-expression / non-literal ALIASES shouldn't surface — the
scanner deliberately ignores anything non-static to keep behavior
predictable (and avoid executing component code)."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n")
aliases, _ = _read_aliases(init, ast)
assert aliases == []
def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None:
init = tmp_path / "__init__.py"
init.write_text("CODEOWNERS = ['@me']\n")
aliases, removal = _read_aliases(init, ast)
assert aliases == []
assert removal is None
def test_read_aliases_handles_syntax_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A broken __init__.py shouldn't crash the alias scanner — it'll
surface as an ImportError elsewhere, but the scanner logs a warning and
yields nothing so other components keep working. The substring pre-filter
only skips files with no ``ALIASES`` token, so this file (which has one)
still reaches the parse."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['x']\ndef broken( :\n")
assert _read_aliases(init, ast) == ([], None)
assert "Could not parse" in caplog.text
def test_read_aliases_handles_read_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable __init__.py logs a warning and yields nothing rather
than aborting the whole component scan."""
missing = tmp_path / "nope" / "__init__.py"
assert _read_aliases(missing, ast) == ([], None)
assert "Could not read" in caplog.text
def test_build_alias_map_aggregates_components(tmp_path: Path) -> None:
"""End-to-end map build over a fake components dir."""
_write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n")
_write_component(tmp_path, "other", "")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, meta_map = _build_alias_map()
assert alias_map == {"oldcomp": "newcomp"}
assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}
def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None:
_write_component(
tmp_path,
"newcomp",
"ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n",
)
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
_, meta_map = _build_alias_map()
assert meta_map["oldcomp"].removal_version == "2028.1.0"
def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None:
"""If two canonical components both claim the same legacy alias,
routing becomes ambiguous — the build must refuse to start so the
conflict surfaces immediately at import time, not later as a
'mysterious wrong component' bug."""
_write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n")
_write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shared"),
):
_build_alias_map()
def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None:
"""If the components directory doesn't exist (unlikely in production,
but possible in some test contexts), we want an empty map rather than
a crash — the rest of the loader can still function."""
fake = tmp_path / "does-not-exist"
with patch("esphome.loader.CORE_COMPONENTS_PATH", fake):
alias_map, meta_map = _build_alias_map()
assert alias_map == {}
assert meta_map == {}
def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None:
"""An alias that names an existing component package is refused: it would
hijack a live domain, and a self-alias (alias == canonical) would send
``_lookup_module`` into infinite recursion."""
# `newcomp` declares itself as an alias — its own package already exists.
_write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shadows an existing component"),
):
_build_alias_map()
# ---- Integration against a synthetic alias map (fake legacy -> esp32) ----
def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None:
"""Force the loader's alias map (used by the finder and get_component).
Patches the lazily-built caches so both ``_get_alias_map`` and the
installed meta-path finder resolve against ``mapping`` regardless of
what the real on-disk scan would produce.
"""
monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping)
def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None:
"""``get_component(<alias>)`` should return the canonical manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
the canonical component without knowing about the alias."""
import esphome.loader as loader_mod
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None)
canonical = get_component("esp32")
aliased = get_component(_FAKE_ALIAS)
assert canonical is not None
assert aliased is canonical
def test_alias_finder_resolves_top_level_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``import esphome.components.<alias>`` resolves to the canonical
module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None)
assert spec is not None
import esphome.components.esp32
import esphome.components.esp32_legacy_alias
assert esphome.components.esp32_legacy_alias is esphome.components.esp32
def test_alias_finder_resolves_submodule_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``from esphome.components.<alias> import boards`` routes through to
``esphome.components.esp32.boards`` — same submodule object on both paths.
The canonical submodule is imported first so its parent module carries
the ``boards`` attribute; ``from <alias> import boards`` then resolves
the aliased parent (via the finder) and reads that same attribute,
rather than triggering a fresh file load under the alias name.
``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None)
assert spec is not None
from esphome.components.esp32 import boards as canonical_boards
from esphome.components.esp32_legacy_alias import boards as aliased_boards
assert aliased_boards is canonical_boards
def test_alias_finder_ignores_non_components_path() -> None:
"""The finder must scope itself to ``esphome.components.<X>`` —
everything else (other esphome submodules, third-party packages) is
left for the normal import machinery."""
finder = _AliasFinder()
assert finder.find_spec("esphome.core", None) is None
assert finder.find_spec("os.path", None) is None
# `esphome.components` itself (no domain segment) is not a candidate.
assert finder.find_spec("esphome.components", None) is None
# A real, non-aliased component domain defers to normal import machinery
# (no component declares an alias in this repo, so the live map is empty).
assert finder.find_spec("esphome.components.logger", None) is None
# ---------------------------------------------------------------------------
# YAML pre-pass: top-level key rename + centralized deprecation warning
# ---------------------------------------------------------------------------
#
# The companion to the loader-side alias map: ``esphome.config`` runs a
# pre-pass over the user's parsed YAML that rewrites legacy top-level keys
# to their canonical names, surfacing a one-shot deprecation warning. These
# tests inject a synthetic alias-metadata map so the rewrite behavior, the
# warning text, and the both-keys-present conflict can be tested in isolation.
def _patch_alias_metadata(
monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta]
) -> None:
monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping)
def test_resolve_component_aliases_renames_legacy_key(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A legacy alias key should be renamed to the canonical key and a
deprecation warning citing the removal version logged."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires
config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}}
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert "oldcomp" not in config
assert config["newcomp"] == {"board": "x"}
assert any(
"'oldcomp:' top-level key is deprecated" in record.message
and "rename it to 'newcomp:'" in record.message
and "2027.6.0" in record.message
for record in caplog.records
)
def test_resolve_component_aliases_dedupes_warning_within_a_run(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Schema validators can run twice (auto-load discovery + final pass)
so the rename pass must emit the warning only once per alias per run.
Deduped via ``CORE.data``; cleared between runs."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases({"oldcomp": {"board": "a"}})
_resolve_component_aliases({"oldcomp": {"board": "b"}})
matches = [
r
for r in caplog.records
if "'oldcomp:' top-level key is deprecated" in r.message
]
assert len(matches) == 1
def test_resolve_component_aliases_rejects_both_keys_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the user has BOTH legacy and canonical keys, silently dropping
one would hide a real misconfiguration. Raise instead."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_canonical_key_after_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The both-keys conflict must be detected even when the canonical key
appears *after* the legacy key in the config (the up-front conflict
scan, not a position-dependent check)."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two different deprecated aliases of the same canonical component is
ambiguous — silently keeping one would hide a misconfiguration."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{
"oldcomp": AliasMeta(canonical="newcomp", removal_version=None),
"legacycomp": AliasMeta(canonical="newcomp", removal_version=None),
},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}}
with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_preserves_key_position(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The renamed canonical key keeps the legacy key's original position
rather than being moved to the end of the config."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}}
_resolve_component_aliases(config)
assert list(config) == ["esphome", "newcomp", "logger"]
def test_resolve_component_aliases_no_op_when_no_legacy_keys(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The pre-pass must be a no-op (no warning, no mutation) for configs
that already use canonical keys."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}}
original = dict(config)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert config == original
assert not any("deprecated" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# ComponentManifest alias properties
# ---------------------------------------------------------------------------
def test_component_manifest_alias_properties_default_empty() -> None:
"""``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None``
when the component module declares neither.
Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the
``getattr(..., default)`` fallback is actually exercised — a bare mock
auto-creates any attribute on access and would never hit the default."""
mod = ModuleType("fake_component")
manifest = ComponentManifest(mod)
assert manifest.aliases == []
assert manifest.alias_removal_version is None
def test_component_manifest_alias_properties_read_module_values() -> None:
"""The properties surface the module's declared values verbatim."""
mod = MagicMock()
mod.ALIASES = ["legacy"]
mod.ALIAS_REMOVAL_VERSION = "2027.6.0"
manifest = ComponentManifest(mod)
assert manifest.aliases == ["legacy"]
assert manifest.alias_removal_version == "2027.6.0"
# ---------------------------------------------------------------------------
# Real (unpatched) lazy build + cache and remaining scanner branches
# ---------------------------------------------------------------------------
def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise the real lazy build over the actual components dir (no patch):
the first call scans and caches, the second returns the cached object."""
monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None)
first = loader_mod._get_alias_map()
second = loader_mod._get_alias_map()
assert isinstance(first, dict)
assert first is second # cached, not rebuilt on the second call
def test_get_alias_metadata_real_build_and_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None)
first = loader_mod.get_alias_metadata()
second = loader_mod.get_alias_metadata()
assert isinstance(first, dict)
assert first is second
def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None:
"""Loose files and directories without an ``__init__.py`` are ignored;
only real component packages contribute to the map."""
(tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n")
(tmp_path / "initless").mkdir() # a dir, but no __init__.py
_write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, _ = _build_alias_map()
assert alias_map == {"legacy": "realcomp"}
def test_read_aliases_ignores_non_assignment_and_complex_targets(
tmp_path: Path,
) -> None:
"""Non-assignment statements and assignments to non-Name targets are
skipped; only simple ``NAME = ...`` assignments are read."""
init = tmp_path / "__init__.py"
init.write_text(
"import os\n" # non-Assign (Import) node -> skipped
"obj.attr = 'v'\n" # Assign with an Attribute target -> skipped
"ALIASES = ['legacy']\n"
)
aliases, _ = _read_aliases(init, ast)
assert aliases == ["legacy"]
# ---------------------------------------------------------------------------
# Finder / loader edge branches
# ---------------------------------------------------------------------------
def test_alias_finder_returns_none_when_canonical_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias points at a canonical *target* that doesn't exist, the
finder declines (returns None) and lets normal import machinery report
the missing module."""
_patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"})
finder = _AliasFinder()
assert finder.find_spec("esphome.components.broken_alias", None) is None
def test_alias_finder_reraises_when_canonical_dependency_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the canonical module exists but fails to import one of its own
dependencies, the finder surfaces that real error instead of masking it
as an unresolved alias (which would silently fall through to a confusing
'no module named <alias>')."""
_patch_alias_map(monkeypatch, {"some_alias": "real_canonical"})
def boom(name: str) -> None:
raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep")
monkeypatch.setattr("esphome.loader.importlib.import_module", boom)
finder = _AliasFinder()
with pytest.raises(ModuleNotFoundError, match="missing_dep"):
finder.find_spec("esphome.components.some_alias", None)
def test_install_alias_finder_is_idempotent() -> None:
"""The finder is installed once at import; calling the installer again is
a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``)."""
before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(before) == 1 # installed at module import time
loader_mod._install_alias_finder()
after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(after) == 1
def test_get_component_alias_to_missing_canonical_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias resolves to a canonical component that can't be loaded,
``get_component`` returns None and caches no bogus manifest."""
_patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"})
loader_mod._COMPONENT_CACHE.pop("ghost_alias", None)
assert get_component("ghost_alias") is None
assert "ghost_alias" not in loader_mod._COMPONENT_CACHE
# ---------------------------------------------------------------------------
# YAML pre-pass: empty-map fast path + validate_config integration
# ---------------------------------------------------------------------------
def test_resolve_component_aliases_noop_when_no_aliases_declared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no component declares an alias, the pre-pass returns immediately
without inspecting or mutating the config."""
from esphome.config import _resolve_component_aliases
monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map
config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}}
original = dict(config)
_resolve_component_aliases(config)
assert config == original
def _default_component_mock() -> Mock:
"""A permissive component mock that validates any config (ALLOW_EXTRA)."""
return Mock(
auto_load=[],
is_platform_component=False,
is_platform=False,
multi_conf=False,
multi_conf_no_default=False,
dependencies=[],
conflicts_with=[],
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
)
@pytest.mark.usefixtures("setup_core")
def test_validate_config_renames_alias_key(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end: a legacy top-level key is renamed to its canonical name
before the rest of ``validate_config`` runs, and validation succeeds.
A real ``esp32`` target platform is included so ``preload_core_config``
is satisfied and validation runs to completion (the renamed canonical
key is loaded via the mocked, permissive component)."""
mock_get_component.side_effect = lambda name: _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {
"legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0")
},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"esp32": {"board": "esp32dev"},
"legacyfoo": {"opt": 1},
}
result = esphome_config.validate_config(raw_config, {})
assert not result.errors, f"unexpected errors: {result.errors}"
assert "newcomp" in result
assert "legacyfoo" not in result
@pytest.mark.usefixtures("setup_core")
def test_validate_config_reports_alias_conflict_as_error(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If both the legacy and canonical keys are present, ``validate_config``
surfaces the conflict as a config error (the ``vol.Invalid`` path)."""
mock_get_component.return_value = _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"newcomp": {"opt": 1},
"legacyfoo": {"opt": 2},
}
result = esphome_config.validate_config(raw_config, {})
assert result.errors
assert "Both 'legacyfoo:'" in str(result.errors)
+264 -6
View File
@@ -24,6 +24,7 @@ from esphome.__main__ import (
_make_crystal_freq_callback,
_redact_with_legacy_fallback,
_resolve_network_devices,
_unresolved_default_error,
_validate_bootloader_binary,
_validate_partition_table_binary,
choose_upload_log_host,
@@ -32,6 +33,8 @@ from esphome.__main__ import (
command_clean_all,
command_config,
command_config_hash,
command_dashboard,
command_idedata,
command_rename,
command_run,
command_update_all,
@@ -156,9 +159,12 @@ def setup_core(
CORE.config = config
CORE.toolchain = Toolchain.PLATFORMIO
if platform is not None:
CORE.data[KEY_CORE] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform
# Production always populates CORE.data[KEY_CORE] before upload/logs run
# (the platform validator sets it during read_config, and
# StorageJSON.apply_to_core sets it on the cache fast path), so mirror
# that here. Tests that exercise platform-specific behavior pass a
# platform explicitly; the rest get a platform-agnostic None.
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if tmp_path is not None:
CORE.config_path = str(tmp_path / f"{name}.yaml")
@@ -436,6 +442,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__substitutions_redacted_without_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Substitution keys have no schema validator, so their values are still
redacted but the unactionable cv.sensitive migration warning is suppressed
(see issue #17225)."""
text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__warns_after_substitutions_block(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The suppression ends at the next top-level key; a sensitive-shaped field
in a later block (a real schema field) still warns, while the substitution
above it does not."""
text = (
"substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n"
)
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
assert "password: \\033[8mhunter2\\033[28m" in out
assert any("'password'" in rec.message for rec in caplog.records)
assert not any("ota_password" in rec.message for rec in caplog.records)
def test_command_config__invokes_legacy_fallback_when_redacting(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
@@ -689,13 +725,30 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
setup_core(
config={
CONF_API: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_MDNS: {CONF_DISABLED: True},
},
address="esp32-a1s.local",
)
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
result = choose_upload_log_host(
default="OTA", check_default=None, purpose=purpose
)
assert result == ["192.168.1.50"]
def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
"""Test OTA device when API is configured (no upload without OTA in config)."""
setup_core(config={CONF_API: {}}, address="192.168.1.100")
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
):
with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"):
choose_upload_log_host(
default="OTA",
check_default=None,
@@ -715,6 +768,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None:
"""A resolvable device with only ota: fails logs with a missing-api message."""
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
with pytest.raises(EsphomeError, match="no 'api:' component is configured"):
choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None:
"""A resolvable device with neither api: nor MQTT logging fails clearly."""
setup_core(address="192.168.1.100")
with pytest.raises(EsphomeError, match="no 'api:' component is configured"):
choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None:
"""A .local host with mDNS disabled and no cache keeps the dashboard hint."""
setup_core(
config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}},
address="esp32-a1s.local",
)
CORE.dashboard = True
msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"])
assert "could not be resolved" in msg
assert "set 'use_address'" in msg
def test_unresolved_default_error_upload_with_ota_is_generic() -> None:
"""With ota: present the upload error stays generic, not transport-specific."""
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
CORE.dashboard = False
msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"])
assert "could not be resolved" in msg
assert "try --device <IP>" in msg
@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None:
"""Test OTA device fallback to MQTT when no OTA/API config."""
@@ -1589,6 +1693,29 @@ def test_upload_program_serial_platformio_platforms(
mock_upload_using_platformio.assert_called_once_with(config, device)
@patch("esphome.__main__.importlib.import_module")
def test_upload_program_serial_unknown_platform(
mock_import: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
) -> None:
"""Serial upload on an unsupported platform falls through to exit_code 1."""
setup_core(platform="custom_platform")
# Module has no upload_program handler, so the SERIAL branch is reached.
mock_import.return_value = MagicMock(spec=[])
mock_get_port_type.return_value = "SERIAL"
config = {}
args = MockArgs()
devices = ["/dev/ttyUSB0"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 1
assert host is None
mock_check_permissions.assert_called_once_with("/dev/ttyUSB0")
def test_upload_using_platformio_creates_signed_bin_for_rp2040(
tmp_path: Path,
) -> None:
@@ -3135,6 +3262,22 @@ def test_has_resolvable_address() -> None:
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
assert has_resolvable_address() is False
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
)
assert has_resolvable_address() is True
# mDNS disabled + .local, cache present but missing this host -> not resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
assert has_resolvable_address() is False
def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function."""
@@ -3654,6 +3797,45 @@ def test_command_wizard(tmp_path: Path) -> None:
mock_wizard.assert_called_once_with(config_file)
def test_command_dashboard_errors_with_device_builder_redirect() -> None:
"""The removed dashboard command points users to ESPHome Device Builder."""
args = MockArgs()
with pytest.raises(EsphomeError, match="esphome-device-builder"):
command_dashboard(args)
@pytest.mark.parametrize(
"argv",
[
["esphome", "dashboard"],
["esphome", "dashboard", "/config"],
# Legacy flags must be accepted so old invocations reach the redirect
# instead of failing on argparse "unrecognized arguments".
["esphome", "dashboard", "--port", "6052", "/config"],
["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"],
[
"esphome",
"dashboard",
"--address",
"0.0.0.0",
"--socket",
"/x",
"--ha-addon",
],
],
)
def test_run_esphome_dashboard_redirects_to_device_builder(
argv: list[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`esphome dashboard` still parses but fails with the redirect message."""
result = run_esphome(argv)
assert result == 1
assert "esphome-device-builder" in caplog.text
def test_command_config_hash(
tmp_path: Path,
capfd: CaptureFixture[str],
@@ -6118,6 +6300,15 @@ def test_should_subscribe_states_env_suppresses() -> None:
assert _should_subscribe_states(args) is False
def test_should_subscribe_states_env_enables() -> None:
"""Test that ESPHOME_LOG_STATES=true enables states by default."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
assert _should_subscribe_states(args) is True
def test_should_subscribe_states_flag_overrides_env() -> None:
"""Test that --states overrides ESPHOME_LOG_STATES=false."""
from esphome.__main__ import _should_subscribe_states
@@ -6202,10 +6393,77 @@ def test_command_run_defaults_subscribe_states_true(
),
patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")),
patch("esphome.__main__.get_serial_ports", return_value=[]),
patch.dict(os.environ, {}, clear=False),
):
# Ensure the default behavior is not affected by an ambient
# ESPHOME_LOG_STATES set in the test runner's environment.
os.environ.pop("ESPHOME_LOG_STATES", None)
result = command_run(args, CORE.config)
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
"""After a BOOTSEL upload (no device) on RP2040, command_run waits for and
picks up the newly enumerated serial port before showing logs."""
setup_core(
config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}},
platform=PLATFORM_RP2040,
)
args = MockArgs()
args.no_logs = False
args.device = None
new_port = MockSerialPort("/dev/ttyACM0", "RP2040 Serial")
with (
patch("esphome.__main__.write_cpp", return_value=0),
patch("esphome.__main__.compile_program", return_value=0),
patch(
"esphome.__main__.choose_upload_log_host",
side_effect=[[], ["/dev/ttyACM0"]],
) as mock_choose,
patch("esphome.__main__.upload_program", return_value=(0, None)),
patch(
"esphome.__main__.get_serial_ports",
side_effect=[[], [new_port]],
),
patch("esphome.__main__._wait_for_serial_port") as mock_wait,
patch("esphome.__main__.show_logs", return_value=0) as mock_show_logs,
):
result = command_run(args, CORE.config)
assert result == 0
mock_wait.assert_called_once_with(known_ports=set())
# The re-detected serial port is used as the preferred logging device.
assert mock_choose.call_args_list[-1].kwargs["default"] == "/dev/ttyACM0"
mock_show_logs.assert_called_once_with(CORE.config, args, ["/dev/ttyACM0"])
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_esp_idf_no_build_errors() -> None:
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1
+98 -22
View File
@@ -1,5 +1,6 @@
"""Tests for esphome.components.nrf52.framework helpers."""
import hashlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
@@ -7,15 +8,32 @@ from unittest.mock import patch
import pytest
from esphome.components.nrf52.framework import (
_TOOLCHAIN_VERSION,
_REQUIREMENTS,
TOOLCHAIN_VERSION,
_get_toolchain_platform_info,
check_and_install,
get_sdk_nrf_tools_path,
)
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _isolate_sdk_nrf_install_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Pin the sdk-nrf install root to a tmp dir for every test.
The default location is the OS user cache dir, so without this any test
that builds framework paths or pre-creates the install dir would touch
the real ``~/.cache/esphome`` on the developer's machine. Tests that need
to exercise the override or default-resolution logic clear/override the
env themselves.
"""
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install"))
@pytest.mark.parametrize(
("system", "machine", "expected"),
[
@@ -52,12 +70,15 @@ _TEST_SDK_VERSION = "2.9.0"
def nrf52_dirs(setup_core: Path) -> SimpleNamespace:
"""Populate CORE and pre-create SDK directories so sentinel.touch() succeeds."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)}
tools = CORE.data_dir / "sdk-nrf"
tools = get_sdk_nrf_tools_path()
python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}"
framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}"
toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION
toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION
for d in (python_env, framework, toolchain_dir):
d.mkdir(parents=True, exist_ok=True)
zephyr_scripts = framework / "zephyr" / "scripts"
zephyr_scripts.mkdir(parents=True, exist_ok=True)
(zephyr_scripts / "requirements.txt").touch()
return SimpleNamespace(
python_env=python_env,
framework=framework,
@@ -94,6 +115,12 @@ def mock_nrf52_ops():
# ---------------------------------------------------------------------------
def _mark_venv_ready(python_env: Path) -> None:
"""Write the venv sentinel with the current requirements hash."""
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
(python_env / ".ready").write_text(requirements_hash, encoding="utf-8")
class TestCheckAndInstall:
def test_all_installed_skips_all_steps(
self,
@@ -101,7 +128,8 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""All three sentinels present → nothing downloaded or compiled."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.toolchain / ".ready").touch()
@@ -121,11 +149,13 @@ class TestCheckAndInstall:
check_and_install()
mock_nrf52_ops.create_venv.assert_called_once()
# pip install west, west init, west update
assert mock_nrf52_ops.run_command_ok.call_count == 3
mock_nrf52_ops.download_from_mirrors.assert_called_once()
mock_nrf52_ops.archive_extract_all.assert_called_once()
# pip install requirements, west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 4
# minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
assert (nrf52_dirs.python_env / ".ready").exists()
assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists()
assert (nrf52_dirs.framework / ".ready").exists()
assert (nrf52_dirs.toolchain / ".ready").exists()
@@ -135,14 +165,15 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Venv ready but framework missing → skip venv creation, run SDK init+update."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
check_and_install()
mock_nrf52_ops.create_venv.assert_not_called()
# west init + west update only (no pip install)
assert mock_nrf52_ops.run_command_ok.call_count == 2
mock_nrf52_ops.download_from_mirrors.assert_called_once()
# west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 3
# minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
def test_toolchain_only_missing(
self,
@@ -150,25 +181,27 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Venv and framework ready → only toolchain downloaded and extracted."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch()
check_and_install()
mock_nrf52_ops.create_venv.assert_not_called()
mock_nrf52_ops.run_command_ok.assert_not_called()
mock_nrf52_ops.download_from_mirrors.assert_called_once()
mock_nrf52_ops.archive_extract_all.assert_called_once()
# minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
def test_west_install_failure_raises(
def test_requirements_install_failure_raises(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing pip install west raises EsphomeError."""
"""Failing pip install -r requirements.txt raises EsphomeError."""
mock_nrf52_ops.run_command_ok.return_value = False
with pytest.raises(EsphomeError, match="Install west"):
with pytest.raises(EsphomeError, match="Install requirements"):
check_and_install()
def test_framework_init_failure_raises(
@@ -177,7 +210,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing west init raises EsphomeError."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
mock_nrf52_ops.run_command_ok.return_value = False
with pytest.raises(EsphomeError, match="Can't initialize"):
@@ -189,7 +222,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing west update raises EsphomeError."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
# init succeeds, update fails
mock_nrf52_ops.run_command_ok.side_effect = [True, False]
@@ -202,7 +235,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".ready").touch()
with patch(
@@ -213,7 +246,50 @@ class TestCheckAndInstall:
args, _ = mock_nrf52_ops.download_from_mirrors.call_args
substitutions = args[1]
assert substitutions["VERSION"] == _TOOLCHAIN_VERSION
assert substitutions["VERSION"] == TOOLCHAIN_VERSION
assert substitutions["sysname"] == "linux"
assert substitutions["machine"] == "x86_64"
assert substitutions["extension"] == "tar.xz"
# ---------------------------------------------------------------------------
# get_sdk_nrf_tools_path tests
# ---------------------------------------------------------------------------
def testget_tools_path_env_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
override = tmp_path / "custom" / "sdk-nrf"
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override))
assert get_sdk_nrf_tools_path() == override.resolve()
@pytest.mark.parametrize("value", ["", " "])
def testget_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD.
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 = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
).resolve()
assert get_sdk_nrf_tools_path() == expected
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
+292
View File
@@ -0,0 +1,292 @@
"""Tests for esphome.components.nrf52 upload_program and run_compile."""
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7
from esphome.components.zephyr.const import (
KEY_BOARD,
KEY_BOOTLOADER,
KEY_EXTRA_BUILD_FILES,
KEY_KCONFIG,
KEY_OVERLAY,
KEY_PM_STATIC,
KEY_PRJ_CONF,
KEY_USER,
KEY_ZEPHYR,
)
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_NRF52,
Toolchain,
)
from esphome.core import CORE, EsphomeError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup_nrf52_core(
bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
toolchain: Toolchain = Toolchain.SDK_NRF,
build_path: Path | None = None,
) -> None:
CORE.name = "test_device"
if build_path is not None:
CORE.build_path = build_path
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_NRF52,
KEY_TARGET_FRAMEWORK: KEY_ZEPHYR,
KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2),
}
CORE.toolchain = toolchain
CORE.data[KEY_ZEPHYR] = {
KEY_BOARD: "adafruit_feather_nrf52840",
KEY_BOOTLOADER: bootloader,
KEY_PRJ_CONF: {},
KEY_OVERLAY: {"": ""},
KEY_EXTRA_BUILD_FILES: {},
KEY_PM_STATIC: [],
KEY_USER: {},
KEY_KCONFIG: "",
}
def _make_paths(tmp_path: Path) -> dict:
return {
"python_executable": tmp_path / "penv" / "python",
"framework_path": tmp_path / "framework",
}
# ---------------------------------------------------------------------------
# Config-reconstruction guard
# ---------------------------------------------------------------------------
class TestUploadProgramConfigGuard:
def test_missing_platform_config_raises(self, setup_core: Path) -> None:
"""upload_program raises EsphomeError when the platform config section is absent."""
from esphome.components.nrf52 import upload_program
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_NRF52,
KEY_TARGET_FRAMEWORK: KEY_ZEPHYR,
}
# KEY_ZEPHYR absent → reconstruction branch is entered
assert KEY_ZEPHYR not in CORE.data
with pytest.raises(EsphomeError, match="platform configuration"):
upload_program(config={}, args=None, host="PYOCD")
# ---------------------------------------------------------------------------
# PYOCD upload path
# ---------------------------------------------------------------------------
class TestUploadProgramPyocd:
def test_pyocd_assembles_west_command(
self, setup_core: Path, tmp_path: Path
) -> None:
"""West flash command must include --runner pyocd and the build dir."""
from esphome.components.nrf52 import upload_program
_setup_nrf52_core(build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
paths = _make_paths(tmp_path)
build_dir = CORE.relative_pioenvs_path(CORE.name)
with (
patch("esphome.components.nrf52.check_and_install"),
patch("esphome.components.nrf52.get_build_paths", return_value=paths),
patch("esphome.components.nrf52.get_build_env", return_value={}),
patch(
"esphome.components.nrf52.run_command_ok", return_value=True
) as mock_run,
):
result = upload_program(config={}, args=None, host="PYOCD")
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert str(paths["python_executable"]) == cmd[0]
assert "west" in cmd
assert "flash" in cmd
assert "--runner" in cmd
assert "pyocd" in cmd
assert "-d" in cmd
assert str(build_dir) in cmd
def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None:
"""A failed west flash must raise EsphomeError."""
from esphome.components.nrf52 import upload_program
_setup_nrf52_core(build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
with (
patch("esphome.components.nrf52.check_and_install"),
patch(
"esphome.components.nrf52.get_build_paths",
return_value=_make_paths(tmp_path),
),
patch("esphome.components.nrf52.get_build_env", return_value={}),
patch("esphome.components.nrf52.run_command_ok", return_value=False),
pytest.raises(EsphomeError, match="pyocd"),
):
upload_program(config={}, args=None, host="PYOCD")
# ---------------------------------------------------------------------------
# Serial DFU upload path
# ---------------------------------------------------------------------------
def _enter_serial_dfu_patches(
stack: ExitStack, host: str, tmp_path: Path, paths: dict
) -> MagicMock:
"""Enter all context managers needed for the serial DFU happy path.
Returns the mock for ``run_command_ok`` so callers can inspect calls.
comports() returns [] on the first call (port disappeared) and a list
containing the host on every subsequent call (port reappeared). Patches
are applied directly on the real pyserial module attributes so they are
visible to the deferred ``import serial[.tools.list_ports] as _x``
statements inside upload_program.
"""
import serial
import serial.tools.list_ports
from esphome.upload_targets import PortType
_comports_calls = [0]
def _comports():
_comports_calls[0] += 1
if _comports_calls[0] == 1:
return [] # port disappeared → disappear loop breaks
return [MagicMock(device=host)] # port back → reappear loop breaks
stack.enter_context(
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL)
)
stack.enter_context(patch("esphome.__main__.check_permissions"))
stack.enter_context(patch("esphome.components.nrf52.check_and_install"))
stack.enter_context(
patch("esphome.components.nrf52.get_build_paths", return_value=paths)
)
stack.enter_context(
patch("esphome.components.nrf52.get_build_env", return_value={})
)
stack.enter_context(patch("time.sleep"))
# Patch directly on the real pyserial module so the deferred imports inside
# upload_program see our mocks regardless of how sys.modules is cached.
stack.enter_context(patch.object(serial, "Serial"))
stack.enter_context(
patch.object(serial.tools.list_ports, "comports", side_effect=_comports)
)
return stack.enter_context(
patch("esphome.components.nrf52.run_command_ok", return_value=True)
)
class TestUploadProgramSerialDfu:
def test_unsupported_bootloader_raises(
self, setup_core: Path, tmp_path: Path
) -> None:
"""An unknown bootloader must raise EsphomeError before touching the port."""
from esphome.components.nrf52 import upload_program
from esphome.upload_targets import PortType
_setup_nrf52_core(
bootloader="unknown_bootloader", build_path=tmp_path / "build"
)
CORE.config_path = tmp_path / "test.yaml"
with (
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
patch("esphome.__main__.check_permissions"),
pytest.raises(EsphomeError, match="Not implemented"),
):
upload_program(config={}, args=None, host="/dev/ttyACM0")
def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None:
"""Missing firmware.zip must raise EsphomeError before opening the serial port."""
from esphome.components.nrf52 import upload_program
from esphome.upload_targets import PortType
_setup_nrf52_core(build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
with (
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
patch("esphome.__main__.check_permissions"),
patch("esphome.components.nrf52.check_and_install"),
patch(
"esphome.components.nrf52.get_build_paths",
return_value=_make_paths(tmp_path),
),
patch("esphome.components.nrf52.get_build_env", return_value={}),
pytest.raises(EsphomeError, match="Firmware not found"),
):
# firmware.zip does not exist on disk → is_file() returns False
upload_program(config={}, args=None, host="/dev/ttyACM0")
def test_serial_dfu_assembles_nordicsemi_command(
self, setup_core: Path, tmp_path: Path
) -> None:
"""Nordicsemi DFU command must include pkg path, port, and --singlebank."""
from esphome.components.nrf52 import upload_program
_setup_nrf52_core(build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
paths = _make_paths(tmp_path)
build_dir = CORE.relative_pioenvs_path(CORE.name)
dfu_package = build_dir / "firmware.zip"
dfu_package.parent.mkdir(parents=True, exist_ok=True)
dfu_package.touch()
host = "/dev/ttyACM0"
with ExitStack() as stack:
mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths)
result = upload_program(config={}, args=None, host=host)
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "nordicsemi.__main__" in cmd
assert "dfu" in cmd
assert "serial" in cmd
assert "-pkg" in cmd
assert str(dfu_package) in cmd
assert "-p" in cmd
assert host in cmd
assert "--singlebank" in cmd
def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None:
"""A failed nordicsemi DFU must raise EsphomeError."""
from esphome.components.nrf52 import upload_program
_setup_nrf52_core(build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
paths = _make_paths(tmp_path)
build_dir = CORE.relative_pioenvs_path(CORE.name)
dfu_package = build_dir / "firmware.zip"
dfu_package.parent.mkdir(parents=True, exist_ok=True)
dfu_package.touch()
host = "/dev/ttyACM0"
with ExitStack() as stack:
mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths)
mock_run.return_value = False
with pytest.raises(EsphomeError, match="serial DFU upload failed"):
upload_program(config={}, args=None, host=host)
+231
View File
@@ -0,0 +1,231 @@
"""Tests for the toolchain-agnostic PlatformIO library converter.
Covers the shared download/parse/resolve/dependency-walk paths in
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
exercised in their own test modules)."""
import json
import logging
from pathlib import Path
import pytest
from esphome.core import Library
import esphome.platformio.library as lib
from esphome.platformio.library import (
ConvertedLibrary,
GitSource,
InvalidLibrary,
LibraryBackend,
Source,
URLSource,
_resolve_registry_version,
check_library_data,
convert_libraries,
)
def _backend(emit=lambda component: None) -> LibraryBackend:
return LibraryBackend(
platform="espressif32", framework="espidf", emit=emit, cache_key="idf"
)
def test_check_library_data_accepts_wildcards():
check_library_data({"platforms": "*", "frameworks": "*"}, "espressif32", "espidf")
def test_check_library_data_accepts_missing_frameworks():
check_library_data({"platforms": "*"}, "espressif32", "espidf")
def test_check_library_data_accepts_empty_manifest():
check_library_data({}, "espressif32", "espidf")
def test_check_library_data_accepts_matching_platform():
check_library_data(
{"platforms": "espressif32", "frameworks": "*"}, "espressif32", "espidf"
)
def test_check_library_data_accepts_matching_framework():
check_library_data(
{"platforms": "*", "frameworks": "espidf"}, "espressif32", "espidf"
)
def test_check_library_data_rejects_unsupported_platform():
with pytest.raises(InvalidLibrary):
check_library_data(
{"platforms": ["other"], "frameworks": "*"}, "espressif32", "espidf"
)
def test_check_library_data_warns_on_framework_mismatch(
caplog: pytest.LogCaptureFixture,
):
# Framework mismatch is a warning, not a hard skip: the library is still
# included so manifests that only list "arduino" (but compile fine under the
# target framework) can be used without forking them.
with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"):
check_library_data(
{"name": "lib", "platforms": "*", "frameworks": ["other"]},
"espressif32",
"espidf",
)
assert "do not include 'espidf'" in caplog.text
def test_source_download_not_implemented():
with pytest.raises(NotImplementedError):
Source().download("x")
def test_gitsource_str_includes_ref_when_present():
assert str(GitSource("http://git/repo.git", "main")) == "http://git/repo.git#main"
assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git"
def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch):
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
dl_calls: list[list[str]] = []
monkeypatch.setattr(
lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls)
)
def fake_extract(fileobj, path):
Path(path).mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(lib, "archive_extract_all", fake_extract)
src = URLSource("http://example.test/lib.tar.gz")
out = src.download("mylib")
assert (out / ".esphome_extracted").is_file()
assert dl_calls == [["http://example.test/lib.tar.gz"]]
# The completion marker means a second download is skipped (cache hit).
out2 = src.download("mylib")
assert out2 == out
assert len(dl_calls) == 1
def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
registry = lib._make_registry_client()
monkeypatch.setattr(
registry,
"fetch_registry_package",
lambda spec: {
"owner": {"username": spec.owner or "owner"},
"name": spec.name,
"versions": [{"name": "1.0.0", "files": [{}]}],
},
)
# A best version exists but none of its files is a compatible package.
monkeypatch.setattr(
registry, "pick_best_registry_version", lambda versions: versions[0]
)
monkeypatch.setattr(registry, "pick_compatible_pkg_file", lambda files: None)
monkeypatch.setattr(lib, "_make_registry_client", lambda: registry)
with pytest.raises(RuntimeError, match="No package file"):
_resolve_registry_version("owner", "pkg", set())
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
self.path.mkdir(parents=True, exist_ok=True)
if self.name in properties:
(self.path / "library.properties").write_text(manifests[self.name])
else:
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
monkeypatch.setattr(
lib,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
),
)
def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
# A manifest provided as library.properties (Arduino style) instead of
# library.json must still be parsed and converted.
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": "name=A\nversion=1.0\n"},
properties=("esphome/A",),
)
emitted: list[ConvertedLibrary] = []
top = convert_libraries(
[Library("esphome/A", "1.0.0", None)], _backend(emitted.append)
)
assert [c.name for c in top] == ["esphome/A"]
assert top[0].data["name"] == "A"
assert emitted[0].data["version"] == "1.0"
def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch):
# A dependency entry lacking a version is malformed and silently skipped.
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}},
)
# No version on the top-level spec exercises the "no requirement" path too.
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch):
# If the git/archive URL probe (urlparse) raises on a malformed value, the
# dependency is still kept and treated as a plain version spec.
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
# An unterminated IPv6 URL makes urlparse raise ValueError.
"dependencies": [{"name": "C", "version": "http://[::1"}],
},
"C": {"name": "C"},
},
)
top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert [d.name for d in top[0].dependencies] == ["C"]
def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch):
# A dependency that declares an incompatible platform is skipped (the
# top-level library still builds).
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "C", "version": "1.0", "platforms": ["avr"]}],
}
},
)
top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert top[0].dependencies == []
@@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables(
)
assert "PLATFORMIO_LIBDEPS_DIR" in os.environ
assert "PYTHONWARNINGS" in os.environ
# Caps git's upward search at the config dir so an uninitialized or
# corrupt parent git repo can't break the framework's `git describe`.
assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split(
os.pathsep
)
# Check command was called correctly — runs PlatformIO as a subprocess
# via the esphome.platformio.runner entry point.
+96
View File
@@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
mock_core.web_port = None
mock_core.target_platform = "esp8266"
mock_core.is_esp32 = False
mock_core.is_nrf52 = False
mock_core.build_path = "/build"
mock_core.firmware_bin = "/build/firmware.bin"
mock_core.loaded_integrations = set()
@@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
assert result.toolchain is None
def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None:
"""Test from_esphome_core captures the framework version on nRF52."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
mock_core = MagicMock()
mock_core.name = "nrf_device"
mock_core.friendly_name = "nRF Device"
mock_core.comment = None
mock_core.address = "nrf.local"
mock_core.web_port = None
mock_core.target_platform = "nrf52"
mock_core.is_esp32 = False
mock_core.is_nrf52 = True
mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}}
mock_core.build_path = "/build/nrf_device"
mock_core.firmware_bin = "/build/nrf_device/firmware.bin"
mock_core.loaded_integrations = set()
mock_core.loaded_platforms = set()
mock_core.config = {}
mock_core.target_framework = "zephyr"
mock_core.toolchain = None
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
assert result.target_platform == "NRF52"
assert result.framework_version == "2.9.2"
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
"""Test StorageJSON.load with valid JSON file."""
storage_data = {
@@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
assert result.esphome_version == "1.14.0" # Should map to esphome_version
def _make_nrf52_storage(
framework_version: str | None = None,
) -> storage_json.StorageJSON:
return storage_json.StorageJSON(
storage_version=1,
name="dev",
friendly_name=None,
comment=None,
esphome_version="2024.1.0",
src_version=1,
address="dev.local",
web_port=None,
target_platform="NRF52",
build_path=Path("/build"),
firmware_bin_path=Path("/build/zephyr/zephyr.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
framework="zephyr",
core_platform="nrf52",
framework_version=framework_version,
)
def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None:
"""Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
storage = _make_nrf52_storage("2.9.2")
path = setup_core / "storage.json"
path.write_text(storage.to_json())
assert json.loads(path.read_text())["framework_version"] == "2.9.2"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.framework_version == "2.9.2"
loaded.apply_to_core()
assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2)
def test_storage_json_nrf52_apply_to_core_without_framework_version(
setup_core: Path,
) -> None:
"""Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
loaded = _make_nrf52_storage(framework_version=None)
assert loaded.framework_version is None
loaded.apply_to_core()
assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE]
def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version(
setup_core: Path,
) -> None:
"""A malformed version string fails with an actionable error at parse time."""
from esphome.core import EsphomeError
loaded = _make_nrf52_storage(framework_version="not-a-version")
with pytest.raises(EsphomeError, match="clean the build"):
loaded.apply_to_core()
def test_storage_json_load_area(tmp_path: Path) -> None:
"""``area`` round-trips through load; absence loads as None."""
file_path = tmp_path / "with_area.json"
+95 -3
View File
@@ -67,15 +67,34 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
want to verify the PIO-cleanup branch (e.g. test_clean_all,
test_clean_all_partial_exists) install their own inner patch which
stacks on top of this one and wins for the duration of their block.
Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to
nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the
same reason: ``clean_all`` removes the machine-global toolchain installs
and their default cache root, which otherwise resolve to the real
``~/.cache/esphome``.
"""
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent"
cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent"
mock_cfg = MagicMock()
mock_cfg.get.side_effect = lambda section, option: (
str(pio_root / option) if section == "platformio" else ""
)
with patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
with (
patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
),
patch.dict(
"os.environ",
{
"ESPHOME_ESP_IDF_PREFIX": str(idf_root),
"ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root),
},
),
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
):
yield
@@ -990,6 +1009,79 @@ def test_clean_all_with_yaml_file(
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_global_idf_install(
mock_core: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the machine-global native ESP-IDF install dir."""
idf_install = tmp_path / "idf_install"
(idf_install / "frameworks").mkdir(parents=True)
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install))
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
assert not idf_install.exists()
assert str(idf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_global_sdk_nrf_install(
mock_core: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the machine-global native sdk-nrf install dir."""
sdk_nrf_install = tmp_path / "sdk_nrf_install"
(sdk_nrf_install / "frameworks").mkdir(parents=True)
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install))
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
assert not sdk_nrf_install.exists()
assert str(sdk_nrf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_default_cache_root(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the default cache root (stale/orphaned installs)."""
cache_root = tmp_path / "cache_root"
(cache_root / "some-old-toolchain").mkdir(parents=True)
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with (
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
caplog.at_level("INFO"),
):
clean_all([str(config_dir)])
assert not cache_root.exists()
assert str(cache_root.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_build_path(
mock_core: MagicMock,
+45
View File
@@ -1395,3 +1395,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None:
assert "\\033[8m" in redacted
assert "\\033[8m" not in raw
assert "\\033[8m" in redacted_again
@pytest.fixture(autouse=True)
def clear_dropped_merge_keys() -> None:
"""Reset the dropped-merge-key queue between tests."""
core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None)
yield
core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None)
def test_merge_include_records_dropped_keys(tmp_path: Path) -> None:
"""A `<<` merge that overlaps an existing key records it (shallow first-wins)."""
(tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n")
(tmp_path / "b.yaml").write_text("api:\n password: secret\n")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n")
with patch.object(core.CORE, "config_path", test_yaml):
result = yaml_util.load_yaml(test_yaml)
# First definition wins; the second `api` block is dropped entirely.
assert result["api"] == {"reboot_timeout": "5min"}
dropped = yaml_util.take_dropped_merge_keys()
assert len(dropped) == 1
key, location = dropped[0]
assert key == "api"
assert "b.yaml" in location
# Queue is drained after being taken.
assert yaml_util.take_dropped_merge_keys() == []
def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None:
"""A `<<` merge with distinct top-level keys drops nothing."""
(tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n")
(tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n")
with patch.object(core.CORE, "config_path", test_yaml):
result = yaml_util.load_yaml(test_yaml)
assert result["api"] == {"reboot_timeout": "5min"}
assert result["logger"] == {"level": "DEBUG"}
assert yaml_util.take_dropped_merge_keys() == []
+117
View File
@@ -0,0 +1,117 @@
"""Tests for the Zephyr backend of the shared PlatformIO library converter."""
from pathlib import Path
import pytest
import esphome.components.zephyr.library as zlib
from esphome.components.zephyr.library import (
generate_cmakelists_txt,
generate_module_yml,
generate_zephyr_modules,
)
from esphome.core import EsphomeError, Library
from esphome.platformio.library import ConvertedLibrary, URLSource
def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary:
c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy"))
c.path = path
return c
def test_generate_module_yml_uses_sanitized_name():
c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy"))
out = generate_module_yml(c)
# "/" -> "__" and " " -> "_" so it's a valid Zephyr module name.
assert "name: owner__My_Lib" in out
assert "cmake: zephyr" in out
def test_generate_cmakelists_txt_basic(tmp_path):
c = _make_component(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "main.c").write_text("int main() {}")
c.data = {}
out = generate_cmakelists_txt(c)
assert "zephyr_library_named(mylib)" in out
assert "zephyr_library_sources(" in out
# Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir),
# backslash-escaped for CMake (matching the output on Windows).
assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out
def test_generate_cmakelists_txt_flags_and_includes(tmp_path):
c = _make_component(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.c").write_text("")
(tmp_path / "include").mkdir()
c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}}
out = generate_cmakelists_txt(c)
assert "zephyr_include_directories(" in out
assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out
assert "zephyr_library_compile_options(" in out
assert "-DFOO" in out
assert "-Wall" in out
assert "zephyr_link_libraries(" in out
assert "-Llibdir" in out
assert "-lm" in out
def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch):
# Two converted libraries: one top-level, one transitive dependency. The
# converter calls backend.emit for both; generate_zephyr_modules must return
# *all* module dirs (not just top-level) so every module is discoverable.
top = _make_component(tmp_path / "top", "top")
(top.path / "src").mkdir(parents=True)
(top.path / "src" / "t.c").write_text("")
dep = _make_component(tmp_path / "dep", "dep")
(dep.path / "src").mkdir(parents=True)
(dep.path / "src" / "d.c").write_text("")
captured = {}
def fake_convert(libraries, backend):
captured["platform"] = backend.platform
captured["framework"] = backend.framework
backend.emit(top)
backend.emit(dep)
return [top]
monkeypatch.setattr(zlib, "convert_libraries", fake_convert)
dirs = generate_zephyr_modules([Library("top", "1.0", None)])
assert dirs == [top.path, dep.path]
# Platform check disabled for Zephyr; framework declared as zephyr.
assert captured["platform"] is None
assert captured["framework"] == "zephyr"
for comp in (top, dep):
assert (comp.path / "zephyr" / "module.yml").is_file()
assert (comp.path / "zephyr" / "CMakeLists.txt").is_file()
def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch):
# The same library referenced under inconsistent specs (e.g. bare vs
# owner-qualified, or git vs registry) resolves to two components with the
# same Zephyr module name, which would collide in zephyr_library_named().
a = _make_component(tmp_path / "a", "esphome/noise-c")
a.path.mkdir(parents=True)
b = _make_component(tmp_path / "b", "esphome/noise-c")
b.path.mkdir(parents=True)
assert a.get_require_name() == b.get_require_name()
def fake_convert(libraries, backend):
backend.emit(a)
backend.emit(b)
return [a]
monkeypatch.setattr(zlib, "convert_libraries", fake_convert)
with pytest.raises(EsphomeError, match="same Zephyr module"):
generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)])