Merge remote-tracking branch 'origin/dev' into integration

This commit is contained in:
J. Nick Koston
2026-07-18 10:21:31 -10:00
466 changed files with 14801 additions and 3978 deletions
@@ -0,0 +1,157 @@
"""Tests for locating build artifacts across the supported toolchain layouts."""
from pathlib import Path
import pytest
from esphome.analyze_memory.toolchain import (
find_elf_path,
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
"""Create <tmp_path>/.esphome/build/<name>, mirroring a real data dir."""
build_path = tmp_path / ".esphome" / "build" / name
build_path.mkdir(parents=True)
return build_path
def _touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
return path
def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None:
"""The native ESP-IDF toolchain writes the ELF under build/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / "build" / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_platformio(tmp_path: Path) -> None:
"""The PlatformIO toolchain writes the ELF under .pioenvs/<name>/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_libretiny(tmp_path: Path) -> None:
"""The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf")
assert find_elf_path(build_path) == elf
@pytest.mark.parametrize(
"relative_elf",
[
# SDK < 2.9.2
"zephyr/zephyr.elf",
# SDK >= 2.9.2 nests the artifacts one level deeper
"zephyr/zephyr/zephyr.elf",
],
)
def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None:
"""Zephyr (nRF52) keeps the ELF under .pioenvs/<name>/zephyr/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf)
assert find_elf_path(build_path) == elf
def test_find_elf_path_missing(tmp_path: Path) -> None:
"""An unknown layout resolves to None rather than a bogus path."""
assert find_elf_path(_make_build_dir(tmp_path)) is None
def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None:
"""The idedata cache sits in the data dir that holds the build dir."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None:
"""Test builds may keep idedata alongside the PlatformIO env."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing idedata resolves to None."""
# Keep the cwd/home fallbacks from finding an unrelated file on this machine
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert find_idedata_path(_make_build_dir(tmp_path)) is None
def test_idedata_candidates_are_what_find_probes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Every advertised candidate is one find_idedata_path actually accepts.
The candidates are reported to the user when idedata is missing, so a list
that drifts from the lookup would send someone hunting in the wrong place.
"""
# Two candidates are relative to the cwd and to home; keep the test from
# writing into the real ones.
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
build_path = _make_build_dir(tmp_path)
candidates = idedata_candidates(build_path)
assert candidates, "no candidates advertised"
for candidate in candidates:
_touch(candidate)
assert find_idedata_path(build_path) == candidate
candidate.unlink()
@pytest.mark.parametrize(
("cxx_path", "expected"),
[
("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"),
("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"),
(
r"C:\tools\bin\xtensa-esp32-elf-g++.exe",
r"C:\tools\bin\xtensa-esp32-elf-gcc.exe",
),
# Nothing to rewrite; leave the path alone
("/tools/bin/clang++", "/tools/bin/clang++"),
],
)
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
"""cc_path is derived from the C++ compiler that compile_commands.json names."""
assert _cc_path_from_cxx(cxx_path) == expected
def test_native_idedata_resolves_toolchain_tools() -> None:
"""The binutils paths are derived from the native ESP-IDF cc_path.
Without cc_path, IDEData.objdump_path raises KeyError and the memory
analysis silently degrades to no component or symbol detail.
"""
idedata = IDEData(
{
"cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"),
"cxx_path": "/tools/bin/xtensa-esp32-elf-g++",
}
)
assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump"
assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf"
@@ -0,0 +1,57 @@
"""Tests for script/ci_memory_impact_extract.py."""
import io
from pathlib import Path
import sys
import pytest
# Add script directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script"))
from ci_memory_impact_extract import main # noqa: E402
_COMPILE_OUTPUT = (
"RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n"
"Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n"
)
@pytest.fixture(autouse=True)
def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int:
monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output))
monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv])
return main()
def test_missing_detailed_analysis_fails(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A build with no usable ELF fails instead of posting a comment without detail."""
build_dir = tmp_path / ".esphome" / "build" / "mydevice"
build_dir.mkdir(parents=True)
out_json = tmp_path / "analysis.json"
rc = _run(
monkeypatch,
_COMPILE_OUTPUT,
["--build-dir", str(build_dir), "--output-json", str(out_json)],
)
assert rc == 1
# The totals are still written so the failure can be diagnosed from the artifact
assert out_json.is_file()
def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Compile output without a build path cannot be analyzed, so it fails."""
assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1
def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Output with no memory totals at all is still a failure."""
assert _run(monkeypatch, "nothing useful here\n", []) == 1
+62 -8
View File
@@ -2,21 +2,22 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.core import EsphomeError
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
on_log runs inside an asyncio protocol callback; if EsphomeError
escapes, the loop reports "Fatal error: protocol.data_received()
call failed.", tears the connection down, and ReconnectLogic loops
forever as the device replays the same crash trace on every
reconnect.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
@@ -43,6 +44,32 @@ def test_decoder_swallows_platform_handler_error() -> None:
assert processor.backtrace_state is False
def test_decoder_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
@@ -61,7 +88,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to PlatformIO; a crash dump can contain many
_decode_pc shells out to the toolchain; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
@@ -112,3 +139,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None:
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,52 @@
"""Tests for LibreTiny board detection, including renamed-board migration."""
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
KEY_LIBRETINY,
)
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
@pytest.fixture
def ln882x_core_data() -> None:
"""Populate CORE the way the ln882x component schema does."""
CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA}
def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None:
"""A current board id resolves its family without warnings."""
result = _detect_variant({CONF_BOARD: "generic-ln882h"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
def test_detect_variant_renamed_board_migrates(
ln882x_core_data: None, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-rename board id validates against the new id, with a warning."""
result = _detect_variant({CONF_BOARD: "generic-ln882hki"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
assert "renamed to 'generic-ln882h'" in caplog.text
def test_detect_variant_renamed_board_does_not_mutate_input(
ln882x_core_data: None,
) -> None:
"""Migration copies the config; the caller's dict keeps the old id."""
value = {CONF_BOARD: "generic-ln882hki"}
_detect_variant(value)
assert value[CONF_BOARD] == "generic-ln882hki"
def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None:
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
+73
View File
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
assert hash1 != hash2
def test_config_hash_ignores_build_path() -> None:
"""Test that config_hash does not depend on the build_path value.
build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must
not make the hash differ between machines.
"""
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}}
hash1 = CORE.config_hash
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
"""Test that Path values under the config dir hash the same everywhere.
Simulates the same project checked out at two different locations; the
absolute paths differ but the layout relative to the config dir is the
same, so the hashes must match.
"""
dir1 = tmp_path / "machine_a" / "project"
dir2 = tmp_path / "machine_b" / "somewhere" / "else"
dir1.mkdir(parents=True)
dir2.mkdir(parents=True)
CORE.reset()
CORE.config_path = dir1 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"}
hash1 = CORE.config_hash
CORE.reset()
CORE.config_path = dir2 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_make_app_name_cpp_no_mac_simple() -> None:
"""Test simple name without MAC suffix returns string literal."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
@@ -1307,3 +1349,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None:
mock_cg.add_library.assert_any_call(
"noise-c", None, "https://github.com/esphome/noise-c.git"
)
def test_esphome_build_internals_are_yaml_only() -> None:
"""Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``.
These knobs (compiler flags, raw PlatformIO options, C/C++ includes,
libraries, build host parallelism, the min-version gate, …) are not
meaningful as visual-editor form fields and a wrong value breaks the
build, so they must never render in a schema-aware UI.
"""
# CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname).
inner = config.CONFIG_SCHEMA.validators[0].schema
markers = {str(k): k for k in inner}
yaml_only_fields = {
CONF_BUILD_PATH,
"platformio_options",
"build_flags",
"environment_variables",
"includes",
"includes_c",
"libraries",
"debug_scheduler",
}
for field in yaml_only_fields:
assert markers[field].visibility is cv.Visibility.YAML_ONLY, field
# Packaging / build-host knobs are real but rarely-touched overrides:
# surface them under the editor's advanced disclosure, not yaml-only.
for field in ("min_version", "compile_process_limit"):
assert markers[field].visibility is cv.Visibility.ADVANCED, field
# A regular device-config field stays on the main form.
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
+65
View File
@@ -22,6 +22,7 @@ from esphome.bundle import (
_add_bytes_to_tar,
_default_target_dir,
_find_used_secret_keys,
add_bundle_file,
extract_bundle,
is_bundle_path,
prepare_bundle_for_compile,
@@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None:
assert "test.yaml" in paths
def test_discover_files_includes_registered_files(tmp_path: Path) -> None:
"""Files registered with add_bundle_file() are included.
The config does not name them, so discovery cannot find them on its own.
"""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_relative_file(tmp_path: Path) -> None:
"""A relative registered path is taken as relative to the config directory.
Not the working directory, which is where Path.resolve() would put it.
"""
_setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(Path("models/model.tflite"))
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None:
"""A registered file outside the config directory is skipped, not bundled."""
_setup_config_dir(tmp_path)
outside = tmp_path / "outside.tflite"
outside.write_text("fake model data")
add_bundle_file(outside)
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files] == ["test.yaml"]
def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None:
"""Registering the same file twice adds it once."""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files].count("models/model.tflite") == 1
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
"""Path objects in validated config are discovered."""
config_dir = _setup_config_dir(
+83 -2
View File
@@ -1,6 +1,6 @@
"""Unit tests for esphome.config module."""
from collections.abc import Generator
from collections.abc import Callable, Generator
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
@@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, yaml_util
from esphome.core import CORE
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@pytest.fixture
@@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
# ---------------------------------------------------------------------------
# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that
# lets a platform component rewrite a pre-platform top-level config.
# ---------------------------------------------------------------------------
def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
result = config.Config()
with (
patch("esphome.config.get_component", return_value=component),
patch("esphome.config._process_auto_load"),
patch("esphome.config._process_platform_config"),
):
config.LoadValidationStep(domain, conf).run(result)
return result
def test_legacy_migrate_rewrites_conf() -> None:
"""A legacy config that the hook migrates is replaced with the new list."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate)
migrate.assert_called_once_with([{"id": "a", "file": "x.png"}])
assert result["image"] == migrated
def test_legacy_migrate_none_keeps_new_format() -> None:
"""When the hook returns None the already-new config is left untouched."""
new_format = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=None)
result = _run_load_step("image", new_format, migrate)
migrate.assert_called_once_with(new_format)
assert result["image"] == new_format
def test_legacy_migrate_absent_hook_is_noop() -> None:
"""A platform component without the hook normalizes without migration."""
result = _run_load_step("image", {"id": "a"}, None)
# Bare dict still gets wrapped into a list by the normal normalization path.
assert result["image"] == [{"id": "a"}]
def test_legacy_migrate_skipped_for_empty_conf() -> None:
"""An empty config short-circuits before the hook is consulted."""
migrate = Mock(return_value=[{"platform": "file"}])
result = _run_load_step("image", [], migrate)
migrate.assert_not_called()
assert result["image"] == []
def test_legacy_migrate_skipped_for_autoload() -> None:
"""An auto-loaded (AutoLoad) config is never migrated."""
migrate = Mock(return_value=[{"platform": "file"}])
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, migrate)
migrate.assert_not_called()
# AutoLoad is dict-like, so normalization wraps it into a single-entry list.
assert result["image"] == [auto]
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.
+101 -5
View File
@@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None:
def test_optional_default_visibility_is_none() -> None:
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
Consumers can read the attribute directly with plain attribute
access; absence (``None``) means "render on the editor's main
form."
The marker stays faithful to what the author wrote: ESPHome does
not encode the default on it. Resolving ``None`` to an effective
visibility is the consumer's job — a schema-aware editor treats an
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
"""
o = cv.Optional("foo")
assert o.visibility is None
@@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None:
assert o.visibility is cv.Visibility.YAML_ONLY
def test_optional_visibility_ui() -> None:
"""``visibility=Visibility.UI`` is recorded on the marker.
``UI`` promotes an ``Optional`` onto the editor's main form,
overriding the consumer's default of ``ADVANCED`` for unset
optionals.
"""
o = cv.Optional("foo", visibility=cv.Visibility.UI)
assert o.visibility is cv.Visibility.UI
def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits.
@@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None:
field — pinning the on-the-wire spelling here keeps the dump
contract stable.
"""
assert str(cv.Visibility.UI) == "ui"
assert str(cv.Visibility.ADVANCED) == "advanced"
assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
@@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None:
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
def test_entity_metadata_visibility_hints() -> None:
"""Entity and value-describing metadata is classified for visual editors.
The headline ``name`` stays on the main form (``UI``); descriptive
metadata (device_class, unit, …), presentation options, and per-entity
integration plumbing (MQTT, web_server ordering) fall to the advanced
disclosure (``ADVANCED``).
"""
advanced = cv.Visibility.ADVANCED
entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema}
assert entity_base["name"].visibility is cv.Visibility.UI
for field in (
"icon",
"internal",
"disabled_by_default",
"entity_category",
"device_id",
):
assert entity_base[field].visibility is advanced, field
mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema}
for field in ("qos", "retain", "discovery", "state_topic", "availability"):
assert mqtt[field].visibility is advanced, field
from esphome.components import binary_sensor, number, sensor
from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA
sensor_markers = {str(k): k for k in sensor.sensor_schema().schema}
for field in (
"unit_of_measurement",
"accuracy_decimals",
"device_class",
"state_class",
"force_update",
):
assert sensor_markers[field].visibility is advanced, field
binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema}
assert binary["device_class"].visibility is advanced
number_markers = {str(k): k for k in number.number_schema(number.Number).schema}
assert number_markers["mode"].visibility is advanced
assert number_markers["device_class"].visibility is advanced
# The whole per-entity web_server block is advanced; children inherit
# via the consumer cascade, so only the parent key carries the hint.
web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema}
assert web["web_server"].visibility is advanced
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)
@@ -1372,9 +1436,41 @@ def test_version_parse_with_extra() -> None:
assert version.extra == "dev20240101"
def test_version_parse_invalid() -> None:
def test_version_parse_without_patch() -> None:
"""A two-part version parses with patch defaulting to 0, so framework
shorthands like '6.0' and '6.0-rc1' are accepted."""
version = cv.Version.parse("6.0")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"",
)
version = cv.Version.parse("6.0-rc1")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"rc1",
)
def test_version_parse_numeric_extra() -> None:
"""Four-part versions keep the trailing component as extra (pioarduino
packaging revisions, e.g. 5.5.3.1)."""
version = cv.Version.parse("5.5.3.1")
assert (version.major, version.minor, version.patch, version.extra) == (
5,
5,
3,
"1",
)
@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""])
def test_version_parse_invalid(value: str) -> None:
with pytest.raises(ValueError, match="Not a valid version number"):
cv.Version.parse("not.a.version")
cv.Version.parse(value)
def test_version_is_beta() -> None:
+43
View File
@@ -1,3 +1,4 @@
import glob
import hashlib
import json
import os
@@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path):
assert str(f2) not in result
def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path):
src = tmp_path / "lib" / "src"
src.mkdir(parents=True)
kept = src / "a.c"
excluded = src / "hasty.c"
kept.write_text("int a;")
excluded.write_text("int b;")
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
assert str(kept) in result
assert str(excluded) not in result
def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch):
# On Windows, glob keeps the pattern's literal separators for non-wildcard
# path components, so the "+" wildcard pattern and the "-" literal pattern
# yield the same file spelled differently and the exclude set difference
# misses it. Backslash is a regular filename character on POSIX (such paths
# fail the final is_file filter), so reproduce the unnormalized-output
# mismatch portably with dot segments, which normpath also collapses.
src = tmp_path / "lib" / "src"
src.mkdir(parents=True)
kept = src / "a.c"
excluded = src / "hasty.c"
kept.write_text("int a;")
excluded.write_text("int b;")
real_glob = glob.glob
def unnormalized_glob(pattern, recursive=False):
if "*" in pattern:
base = str(tmp_path)
return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"]
return real_glob(pattern, recursive=recursive)
monkeypatch.setattr(glob, "glob", unnormalized_glob)
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
assert [Path(r).name for r in result] == ["a.c"]
assert str(kept) in result
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
+74 -1
View File
@@ -317,7 +317,7 @@ def espidf_mocks(setup_core: Path):
# extracted-marker touch writes into.
_get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True)
with (
patch("esphome.espidf.framework.rmdir"),
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
patch(
"esphome.espidf.framework.download_from_mirrors",
return_value="https://example.com/idf.tar.xz",
@@ -344,6 +344,7 @@ def espidf_mocks(setup_core: Path):
run_ok=run_ok,
tool_paths=tool_paths,
clone=clone,
rmdir=rmdir_mock,
)
@@ -358,6 +359,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None:
espidf_mocks.extract.assert_called_once()
espidf_mocks.venv.assert_called_once()
espidf_mocks.clone.assert_not_called()
# the tool download cache (<IDF_TOOLS_PATH>/dist) is pruned after install
espidf_mocks.rmdir.assert_any_call(
get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache"
)
def test_check_esp_idf_install_dist_prune_failure_ignored(
espidf_mocks: SimpleNamespace,
) -> None:
"""A failure to prune the tool download cache must not fail the install."""
tools_dist = get_idf_tools_path() / "dist"
def rmdir_side_effect(directory: Path, msg: str | None = None) -> None:
if directory == tools_dist:
raise RuntimeError("cannot remove dist")
espidf_mocks.rmdir.side_effect = rmdir_side_effect
# install still succeeds despite the failed prune
framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True)
assert framework_path == _get_framework_path(_IDF_VERSION)
def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None:
@@ -478,6 +500,34 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
espidf_mocks.venv.assert_called_once()
@pytest.mark.parametrize(
("lib", "expect_hint"),
[
(None, True),
("libusb-1.0.so.0", False),
],
)
def test_check_esp_idf_install_failure_libusb_hint(
espidf_mocks: SimpleNamespace,
caplog: pytest.LogCaptureFixture,
lib: str | None,
expect_hint: bool,
) -> None:
"""A failed tools install only shows the libusb hint when libusb-1.0 is
actually missing."""
espidf_mocks.run_ok.return_value = False
# Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe
# everywhere (unlike faking Windows, which pulls in winreg on other hosts)
with (
patch("esphome.espidf.framework.find_library", return_value=lib),
patch("esphome.espidf.framework.platform.system", return_value="Linux"),
caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"),
pytest.raises(RuntimeError, match="framework installation failure"),
):
check_esp_idf_install(_IDF_VERSION, force=True)
assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint
def test_check_esp_idf_install_unparseable_version(
espidf_mocks: SimpleNamespace,
) -> None:
@@ -489,6 +539,29 @@ def test_check_esp_idf_install_unparseable_version(
espidf_mocks.extract.assert_called_once()
@pytest.mark.parametrize(
("version", "short_version"),
[
("6.0.0", "6.0"),
("6.0.0-rc1", "6.0-rc1"),
("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases
],
)
def test_check_esp_idf_install_short_version_substitution(
espidf_mocks: SimpleNamespace, version: str, short_version: str | None
) -> None:
"""SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror
template is never tried for versions whose tag cannot exist."""
_get_framework_path(version).mkdir(parents=True, exist_ok=True)
check_esp_idf_install(version, force=True)
# First call downloads the framework archive; a later call fetches the
# constraints file with its own substitutions.
substitutions = espidf_mocks.download.call_args_list[0][0][1]
assert substitutions.get("SHORT_VERSION") == short_version
assert substitutions["VERSION"] == version
# ---------------------------------------------------------------------------
# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport)
# ---------------------------------------------------------------------------
+128 -3
View File
@@ -5,10 +5,13 @@
import json
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
import pytest
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
from esphome.core import CORE, EsphomeError
from esphome.espidf import toolchain
@@ -100,7 +103,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
@@ -108,7 +111,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_not_called()
assert result == {"cxx_path": "cached"}
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
@@ -131,6 +158,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)
@@ -184,6 +238,77 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_cmake_output_without_build_dir(setup_core: Path) -> None:
"""A build dir that was never created raises EsphomeError.
Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which
the log stack-trace decoder doesn't recognise as a decode failure.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
assert not build_dir.exists()
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None:
"""A build dir that exists but was never configured raises EsphomeError."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_with_configured_build(setup_core: Path) -> None:
"""A configured build still runs cmake and caches the output.
The missing-build guard must not get in the way of a real build.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
(build_dir / "CMakeCache.txt").write_text("")
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n"
)
with (
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run,
):
assert toolchain._get_cmake_output(build_dir) == completed.stdout
# Second call is served from the cache rather than re-running cmake.
assert toolchain._get_cmake_output(build_dir) == completed.stdout
mock_run.assert_called_once()
assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line")
def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
setup_core: Path,
) -> None:
"""The build check runs before the env is resolved.
Resolving the env calls check_esp_idf_install(), which can download and
extract the whole framework. A doomed call must never start that.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
with (
patch.object(toolchain, "_get_idf_env") as mock_env,
patch.object(toolchain.subprocess, "run") as mock_run,
pytest.raises(EsphomeError),
):
toolchain._get_cmake_output(build_dir)
mock_env.assert_not_called()
mock_run.assert_not_called()
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
+108 -3
View File
@@ -16,6 +16,7 @@ import zipfile
import pytest
import requests as req
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_detect_archive_root,
@@ -546,6 +547,99 @@ class TestDownloadFromMirrors:
)
assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin"
def test_template_with_missing_substitution_is_skipped(
self, tmp_path: Path
) -> None:
"""A template referencing an unavailable substitution is skipped, not
formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0
framework versions)."""
with patch(
"requests.get",
return_value=_mock_response(b"x"),
) as mock_get:
url = download_from_mirrors(
[
"https://example.com/{SHORT_VERSION}.bin",
"https://example.com/{VERSION}.bin",
],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert url == "https://example.com/1.2.3.bin"
assert mock_get.call_count == 1
def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None:
with (
patch("requests.get") as mock_get,
pytest.raises(EsphomeError, match="No mirror URL template matched") as ei,
):
download_from_mirrors(
["https://example.com/{MISSING}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
mock_get.assert_not_called()
# The skipped template and its missing substitution are named
assert "https://example.com/{MISSING}.bin" in str(ei.value)
assert "MISSING" in str(ei.value)
def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None:
"""When downloads fail, templates that were skipped for missing
substitutions are also listed so a typo'd custom mirror is
attributable."""
with (
patch(
"requests.get",
return_value=_mock_response(b"", ok=False),
),
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(
[
"https://example.com/{TYPO}.bin",
"https://example.com/{VERSION}.bin",
],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
message = str(ei.value)
assert "https://example.com/1.2.3.bin" in message
assert (
"https://example.com/{TYPO}.bin\n not applicable (TYPO not available)"
in message
)
def test_malformed_template_warns_and_is_reported(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A structurally malformed template is an authoring error: warned
about even when another mirror succeeds, and named in the aggregate
error when everything fails."""
with (
patch("requests.get", return_value=_mock_response(b"x")),
caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"),
):
url = download_from_mirrors(
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert url == "https://example.com/1.2.3.bin"
assert "malformed mirror URL template" in caplog.text
with (
patch("requests.get", return_value=_mock_response(b"", ok=False)),
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert "https://example.com/{oops.bin\n skipped (ValueError(" in str(
ei.value
)
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
with patch(
"requests.get",
@@ -559,15 +653,26 @@ class TestDownloadFromMirrors:
assert url == "https://mirror2.com/f"
assert (tmp_path / "out.bin").read_bytes() == b"second"
def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None:
def test_all_mirrors_fail_raises_error_listing_every_attempt(
self, tmp_path: Path
) -> None:
with (
patch(
"requests.get",
return_value=_mock_response(b"", ok=False),
),
pytest.raises(req.HTTPError),
pytest.raises(EsphomeError, match="all mirrors") as excinfo,
):
download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin")
download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
tmp_path / "out.bin",
)
# Every attempted URL appears in the message, and the first mirror's
# exception (the primary URL, usually the one that matters) is chained.
assert "https://mirror1.com/f" in str(excinfo.value)
assert "https://mirror2.com/f" in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, req.HTTPError)
def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="empty mirrors list"):
+2 -2
View File
@@ -170,9 +170,9 @@ def setup_core(
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if tmp_path is not None:
CORE.config_path = str(tmp_path / f"{name}.yaml")
CORE.config_path = tmp_path / f"{name}.yaml"
CORE.name = name
CORE.build_path = str(tmp_path / ".esphome" / "build" / name)
CORE.build_path = tmp_path / ".esphome" / "build" / name
@pytest.fixture
+220
View File
@@ -1,22 +1,30 @@
"""Tests for esphome.components.nrf52.framework helpers."""
import hashlib
import os
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.components.nrf52.framework import (
_PLATFORMIO_PENV_REQUIREMENTS,
_REQUIREMENTS,
TOOLCHAIN_VERSION,
_get_penv_site_packages,
_get_platformio_penv_path,
_get_toolchain_platform_info,
check_and_install,
get_build_env,
get_sdk_nrf_tools_path,
setup_platformio_python_env,
)
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_python_env_executable_path
@pytest.fixture(autouse=True)
@@ -252,6 +260,218 @@ class TestCheckAndInstall:
assert substitutions["extension"] == "tar.xz"
# ---------------------------------------------------------------------------
# setup_platformio_python_env tests
# ---------------------------------------------------------------------------
def _platformio_requirements_hash() -> str:
return hashlib.sha256(
_REQUIREMENTS.read_bytes()
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
).hexdigest()
@pytest.fixture
def platformio_penv_dir() -> Path:
"""Pre-create the PlatformIO penv dir so sentinel writes succeed.
create_venv is mocked in these tests, so the directory it would have
created must exist for ``sentinel.write_text`` to work.
"""
penv_path = _get_platformio_penv_path()
penv_path.mkdir(parents=True, exist_ok=True)
return penv_path
class TestSetupPlatformioPythonEnv:
def test_fresh_install_creates_venv_and_sets_env(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""No sentinel → venv created, requirements installed, env exported."""
with patch.dict(os.environ):
os.environ.pop("PYTHONPATH", None)
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_called_once()
mock_nrf52_ops.create_venv.assert_called_once_with(
platformio_penv_dir, msg="PlatformIO toolchain"
)
mock_nrf52_ops.run_command_ok.assert_called_once()
cmd = mock_nrf52_ops.run_command_ok.call_args[0][0]
assert cmd[1:4] == ["-m", "pip", "install"]
assert "-r" in cmd
assert str(_REQUIREMENTS) in cmd
for requirement in _PLATFORMIO_PENV_REQUIREMENTS:
assert requirement in cmd
sentinel = platformio_penv_dir / ".ready"
assert sentinel.read_text(encoding="utf-8") == (
_platformio_requirements_hash()
)
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
assert os.environ["PYTHONPATH"] == site_packages
bin_dir = str(
get_python_env_executable_path(platformio_penv_dir, "python").parent
)
assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir
def test_ready_sentinel_skips_install_but_sets_env(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Current sentinel → no install work, env vars still exported."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
with patch.dict(os.environ):
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_not_called()
mock_nrf52_ops.create_venv.assert_not_called()
mock_nrf52_ops.run_command_ok.assert_not_called()
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
def test_stale_sentinel_reinstalls(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A sentinel from different requirements → venv rebuilt from scratch."""
sentinel = platformio_penv_dir / ".ready"
sentinel.write_text("stale-hash", encoding="utf-8")
with patch.dict(os.environ):
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_called_once()
mock_nrf52_ops.create_venv.assert_called_once()
mock_nrf52_ops.run_command_ok.assert_called_once()
assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash()
def test_install_failure_raises(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing pip install raises EsphomeError and writes no sentinel."""
mock_nrf52_ops.run_command_ok.return_value = False
with (
patch.dict(os.environ),
pytest.raises(
EsphomeError, match="Install requirements for PlatformIO toolchain"
),
):
setup_platformio_python_env()
assert not (platformio_penv_dir / ".ready").exists()
def test_repeated_calls_do_not_duplicate_env_entries(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Compile then upload in one process must not grow PYTHONPATH/PATH."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
bin_dir = str(
get_python_env_executable_path(platformio_penv_dir, "python").parent
)
with patch.dict(os.environ):
setup_platformio_python_env()
setup_platformio_python_env()
assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1
assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1
def test_existing_pythonpath_preserved(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A pre-existing PYTHONPATH keeps its entries after the venv entry."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
setup_platformio_python_env()
assert os.environ["PYTHONPATH"] == os.pathsep.join(
[site_packages, "/existing/path"]
)
@pytest.mark.parametrize(
("os_name", "expected_parts"),
[
(
"posix",
(
"lib",
f"python{sys.version_info.major}.{sys.version_info.minor}",
"site-packages",
),
),
("nt", ("Lib", "site-packages")),
],
)
def test_get_penv_site_packages(
tmp_path: Path, os_name: str, expected_parts: tuple[str, ...]
) -> None:
penv_path = tmp_path / "penv"
with patch("os.name", os_name):
assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts)
# ---------------------------------------------------------------------------
# get_build_env tests
# ---------------------------------------------------------------------------
def test_get_build_env(
nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root.
ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake
explicitly consumes (from the environment) and uses as a find_package
HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in
containerized non-root builds and was removed.
"""
monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept")
env = get_build_env()
tools = get_sdk_nrf_tools_path()
venv_bin_dir = get_python_env_executable_path(
tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python"
).parent
assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep)
assert env["ZEPHYR_BASE"] == str(
tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr"
)
# Toolchain root, not the cmake/ subdir
assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(
tools / "toolchains" / TOOLCHAIN_VERSION
)
assert "Zephyr-sdk_DIR" not in env
# The rest of the process environment is inherited
assert env["SOME_PREEXISTING_VAR"] == "kept"
# ---------------------------------------------------------------------------
# get_sdk_nrf_tools_path tests
# ---------------------------------------------------------------------------
+66
View File
@@ -146,6 +146,72 @@ class TestUploadProgramPyocd:
upload_program(config={}, args=None, host="PYOCD")
# ---------------------------------------------------------------------------
# PlatformIO toolchain paths
# ---------------------------------------------------------------------------
class TestRunCompilePlatformio:
def test_prepares_python_env_and_delegates_to_platformio(
self, setup_core: Path, tmp_path: Path
) -> None:
"""The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds."""
from esphome.components.nrf52 import run_compile
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
with patch(
"esphome.components.nrf52.setup_platformio_python_env"
) as mock_setup:
assert run_compile(args=None, config={}) is False
mock_setup.assert_called_once_with()
class TestUploadProgramSerialPlatformio:
def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple:
from esphome.components.nrf52 import upload_program
from esphome.upload_targets import PortType
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
with (
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
patch("esphome.__main__.check_permissions"),
patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup,
patch(
"esphome.platformio.toolchain.run_platformio_cli_run",
return_value=run_result,
) as mock_run,
):
result = upload_program(config={}, args=None, host=host)
return result, mock_setup, mock_run
def test_serial_upload_prepares_env_and_runs_platformio(
self, setup_core: Path, tmp_path: Path
) -> None:
"""Serial upload with the PlatformIO toolchain runs pio with -t upload."""
host = "/dev/ttyACM0"
result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0)
assert result is True
mock_setup.assert_called_once_with()
mock_run.assert_called_once()
run_args = mock_run.call_args[0]
assert "-t" in run_args
assert "upload" in run_args
assert "--upload-port" in run_args
assert host in run_args
def test_serial_upload_failure_raises(
self, setup_core: Path, tmp_path: Path
) -> None:
"""A non-zero PlatformIO result must raise EsphomeError."""
with pytest.raises(EsphomeError, match="Upload failed"):
self._upload("/dev/ttyACM0", tmp_path, run_result=1)
# ---------------------------------------------------------------------------
# Serial DFU upload path
# ---------------------------------------------------------------------------
+51
View File
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
assert value == "hunter2"
def test_dump_path_without_relative_to_is_unchanged() -> None:
"""Test that Path values dump as str(path) when relative_to is not given."""
path = Path("some") / "dir" / "file.ttf"
output = yaml_util.dump({"file": path})
assert output.strip() == f"file: {path}"
def test_dump_path_relative_to_anchor_dir() -> None:
"""Test that Path values under relative_to dump as relative POSIX paths."""
anchor = Path("/config/esphome").absolute()
data = {"file": anchor / "fonts" / "arial.ttf"}
output = yaml_util.dump(data, relative_to=anchor)
assert output.strip() == "file: fonts/arial.ttf"
def test_dump_path_outside_anchor_dir_walks_up() -> None:
"""Test that Path values outside relative_to walk up with ".." segments."""
anchor = Path("/config/esphome").absolute()
outside = Path("/config/fonts/file.ttf").absolute()
output = yaml_util.dump({"file": outside}, relative_to=anchor)
assert output.strip() == "file: ../fonts/file.ttf"
def test_dump_path_with_dotdot_segments_is_normalized() -> None:
"""Test that ".." segments do not defeat relativization.
A path like /config/other/../esphome/fonts/x.ttf is under the anchor
once normalized, so it must dump as a plain relative path.
"""
anchor = Path("/config/esphome").absolute()
path = Path("/config/other/../esphome/fonts/x.ttf").absolute()
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: fonts/x.ttf"
def test_dump_path_dotdot_reference_outside_anchor() -> None:
"""Test the relative_config_path("../...") shape stays relative."""
anchor = Path("/config/esphome").absolute()
path = anchor / ".." / "shared" / "font.ttf"
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: ../shared/font.ttf"
def test_dump_relative_to_does_not_leak_between_calls() -> None:
"""Test that the relative_to flag is scoped to a single dump call."""
anchor = Path("/config/esphome").absolute()
path = anchor / "fonts" / "arial.ttf"
assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor)
assert yaml_util.dump({"file": path}).strip() == f"file: {path}"
def test_dump__redacts_sensitive_str_by_default() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8mhunter2\\033[28m" in out