mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 10:31:29 +00:00
Merge branch 'dev' into store-yaml-firmware
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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)])
|
||||
Reference in New Issue
Block a user