Merge branch 'dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-08-12 17:27:35 +12:00
committed by GitHub
984 changed files with 50402 additions and 7012 deletions
@@ -143,8 +143,8 @@ def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
def test_native_idedata_resolves_toolchain_tools() -> None:
"""The binutils paths are derived from the native ESP-IDF cc_path.
Without cc_path, IDEData.objdump_path raises KeyError and the memory
analysis silently degrades to no component or symbol detail.
Without cc_path, IDEData.objdump_path raises EsphomeError and the
memory analysis silently degrades to no component or symbol detail.
"""
idedata = IDEData(
{
@@ -0,0 +1,371 @@
"""Invariant tests for esphome/components/api/api.proto and its generated code.
These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition
(API 1.15) against regressions that protoc-based codegen would not catch on
its own, without requiring protoc to be installed at test time:
* script/api_protobuf/api_protobuf.py skips any field marked
`[deprecated = true]` completely -- it generates no C++ for it at all, so
the device silently stops sending that value. Six DeviceInfoResponse fields
were superseded by DeviceCapabilitiesResponse but must keep being sent for
backward compatibility with clients older than API 1.15. If a future edit
"tidies up" by marking one of them deprecated, this file breaks that field
for every existing client with nothing else in CI noticing.
* Field numbers are the wire protocol, not the field names. Renaming a field
is harmless; renumbering it is a silent breaking change, because an old
client still decodes by number. This file pins the field number of each of
the six superseded DeviceInfoResponse fields and of every field on the new
DeviceCapabilitiesResponse/BluetoothProxyCapabilities/
VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a
well-intentioned reshuffle of api.proto gets caught here instead of on a
device in the field.
* Message wire ids must be unique, and the new capabilities RPC must stay
authenticated-only.
Group A below asserts on the checked-in generated files (api_pb2.h /
api_pb2.cpp), since "the field is present in the generated C++" is exactly
equivalent to "the device still sends it". Group B parses api.proto as plain
text (no protoc). Group C checks the advertised API minor version.
"""
from __future__ import annotations
from pathlib import Path
import re
import esphome
API_DIR = Path(esphome.__file__).parent / "components" / "api"
PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8")
HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8")
CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8")
API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8")
# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse
# as of API 1.15 but must still be generated (and therefore still sent) for
# backward compatibility with older clients.
SUPERSEDED_FIELDS: dict[str, int] = {
"bluetooth_proxy_feature_flags": 15,
"voice_assistant_feature_flags": 17,
"bluetooth_mac_address": 18,
"zwave_proxy_feature_flags": 23,
"zwave_home_id": 24,
"serial_proxies": 25,
}
# Field numbers on the new capability messages. These are a frozen wire
# contract from the moment they ship: an old client decodes a sub-message
# field purely by number, so renumbering any of these -- even without
# touching a name -- silently corrupts what every already-deployed client
# reads. Keyed by message name so the next capability sub-message is a
# data-only addition here.
NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"DeviceCapabilitiesResponse": {
"bluetooth_proxy": 1,
"voice_assistant": 2,
"zwave_proxy": 3,
"serial_proxies": 4,
},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
},
"VoiceAssistantCapabilities": {
"feature_flags": 1,
},
"ZWaveProxyCapabilities": {
"feature_flags": 1,
"home_id": 2,
},
}
# Fields that are genuinely dead and are expected to carry `deprecated=true`.
# Used to prove the deprecated-detection logic below actually detects
# deprecation rather than trivially passing.
GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = (
"legacy_bluetooth_proxy_version",
"legacy_voice_assistant_version",
)
DEPRECATED_FIELD_TRAP = (
"script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = "
"true]` completely, generating no C++ for them at all. Marking this field "
"deprecated would silently stop the device from ever sending it, breaking "
"every existing client that still reads it from DeviceInfoResponse."
)
def _extract_braced_region(text: str, anchor_pattern: str) -> str:
"""Return the region of `text` starting at the first match of
`anchor_pattern` up to the matching closing brace (inclusive), using
brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop
inside a function body) don't cause a premature stop.
"""
anchor_match = re.search(anchor_pattern, text)
if anchor_match is None:
raise AssertionError(f"could not find a match for {anchor_pattern!r}")
start = anchor_match.start()
open_brace = text.index("{", start)
depth = 0
for i in range(open_brace, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}")
def _extract_class_body(header_text: str, class_name: str) -> str:
"""Return the body of a generated C++ class, scoped so a field name that
also happens to exist on some other class cannot satisfy the assertion.
"""
return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b")
def _extract_function_body(cpp_text: str, qualified_name: str) -> str:
"""Return the body of a generated `Class::method(...)` definition."""
return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(")
def _extract_proto_message(proto_text: str, message_name: str) -> str:
"""Return the body of a top-level `message Name { ... }` block from the
.proto source. Proto message bodies here contain no nested `{`/`}` of
their own (options use parens, not braces), so a non-greedy match up to
the first line that is just `}` is sufficient and keeps the parsing
simple.
"""
match = re.search(
rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}",
proto_text,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `message {message_name}` in api.proto")
return match.group(1)
def _extract_rpc_body(proto_text: str, rpc_name: str) -> str:
"""Return the option body of an `rpc name (...) returns (...) { ... }`
declaration from the APIConnection service, robust to it being written
on one line (`{}`) or spread across several with options inside.
"""
match = re.search(
rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}",
proto_text,
re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto")
return match.group(1)
def _field_declaration_line(message_body: str, field_name: str) -> str:
"""Return the single source line declaring `field_name` inside a proto
message body (all fields here are declared on one line).
"""
for line in message_body.splitlines():
if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line):
return line
raise AssertionError(
f"could not find a field declaration for {field_name!r} in the given message body"
)
# ==================== Group A: generated files ====================
def test_superseded_device_info_fields_still_declared_in_header() -> None:
"""Each superseded field must still be a real member of DeviceInfoResponse
in api_pb2.h -- not merely present somewhere in the file. Several of these
names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an
unscoped substring search over the whole header would pass even if the
field were removed from DeviceInfoResponse.
"""
class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
assert re.search(rf"\b{field_name}\b", class_body), (
f"{field_name} is missing from the DeviceInfoResponse class body in "
f"api_pb2.h. {DEPRECATED_FIELD_TRAP}"
)
def test_superseded_device_info_fields_still_encoded_and_sized() -> None:
"""Each superseded field must still be touched by DeviceInfoResponse's
generated encode() and calculate_size(), i.e. it is still put on the wire.
"""
encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode")
size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size")
for field_name in SUPERSEDED_FIELDS:
assert f"this->{field_name}" in encode_body, (
f"DeviceInfoResponse::encode() no longer references {field_name}. "
f"{DEPRECATED_FIELD_TRAP}"
)
assert f"this->{field_name}" in size_body, (
f"DeviceInfoResponse::calculate_size() no longer references "
f"{field_name}. {DEPRECATED_FIELD_TRAP}"
)
def test_new_capability_classes_present_in_header() -> None:
"""The new response message and its capability sub-messages must exist as
generated classes.
"""
for class_name in (
"DeviceCapabilitiesResponse",
"BluetoothProxyCapabilities",
"VoiceAssistantCapabilities",
"ZWaveProxyCapabilities",
):
assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), (
f"expected a generated class named {class_name} in api_pb2.h"
)
# ==================== Group B: api.proto source text ====================
def test_all_message_ids_are_unique() -> None:
"""Every `option (id) = N;` in api.proto must be unique. Two messages
sharing a wire id would make the client and server misinterpret each
other's messages -- nothing else currently checks this.
"""
ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)]
assert ids, "did not find any `option (id) = N;` declarations in api.proto"
duplicates = sorted({value for value in ids if ids.count(value) > 1})
assert not duplicates, (
f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each "
"message must have a unique wire id."
)
def test_device_capabilities_request_has_id_149() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`"
assert int(match.group(1)) == 149, (
f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_device_capabilities_response_has_id_150() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`"
assert int(match.group(1)) == 150, (
f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
them (see module docstring). The second half of this test proves the
deprecated-detection itself works: two genuinely dead fields
(legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must
still be detected as deprecated, so the first half isn't vacuously true.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" not in line, (
f"{field_name} in DeviceInfoResponse is marked deprecated in "
f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}"
)
for field_name in GENUINELY_DEPRECATED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" in line, (
f"expected {field_name} to still carry `deprecated=true` in "
f"api.proto ({line.strip()!r}). If this fails, the deprecated "
"detection used above is broken, and the sibling assertion that "
"the superseded fields are NOT deprecated is not testing anything."
)
def test_superseded_fields_keep_their_wire_numbers() -> None:
"""Each superseded field must stay on the field number recorded in
SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field
number, so renumbering one of these -- even without touching its name --
would make an old client read a completely different value out of the
wire, with nothing else in CI noticing.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name, field_number in SUPERSEDED_FIELDS.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} in DeviceInfoResponse is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field numbers "
"are the wire protocol -- renumbering this field silently breaks "
"every existing client that still decodes DeviceInfoResponse by "
"the old numbering."
)
def test_capability_message_fields_keep_their_wire_numbers() -> None:
"""Every field on DeviceCapabilitiesResponse and its three capability
sub-messages must stay on the field number recorded in
NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but
the moment a device ships with them, their field numbers are a frozen
wire contract -- a client decodes a sub-message field purely by number,
so a later "cleanup" that renumbers one of these would silently corrupt
what every already-deployed client reads, with nothing else in CI
noticing.
"""
for message_name, fields in NEW_CAPABILITY_FIELDS.items():
body = _extract_proto_message(PROTO_TEXT, message_name)
for field_name, field_number in fields.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} on {message_name} is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field "
"numbers are the wire protocol -- renumbering this field "
"silently breaks every existing client that decodes this "
"message by the old numbering."
)
def test_device_capabilities_rpc_requires_authentication() -> None:
"""The `device_capabilities` RPC must not set
`option (needs_authentication) = false;` (or set it to anything at all).
Leaving it unset makes it inherit needs_authentication = true, keeping
capability data behind authentication (and encryption, when configured).
"""
body = _extract_rpc_body(PROTO_TEXT, "device_capabilities")
assert "needs_authentication" not in body, (
"rpc device_capabilities sets a `needs_authentication` option in "
"api.proto. It must stay unset so it inherits needs_authentication = "
"true; otherwise device capability data could be requested over an "
"unauthenticated connection."
)
# ==================== Group C: advertised API version ====================
def test_api_version_minor_is_at_least_15() -> None:
"""Clients gate sending DeviceCapabilitiesRequest on seeing
api_version >= 1.15 in HelloResponse. Regressing api_version_minor below
15 would make every client believe capabilities are unsupported even
though the RPC exists, so this must never go backwards. Use >= rather
than == so the next unrelated minor-version bump doesn't need to touch
this test.
"""
match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT)
assert match is not None, (
"could not find `resp.api_version_minor = N;` in api_connection.cpp"
)
minor = int(match.group(1))
assert minor >= 15, (
f"api_version_minor is {minor}, but device_capabilities requires "
"clients to see api_version >= 1.15 in HelloResponse before they will "
"ever request it."
)
@@ -0,0 +1,93 @@
"""Unit tests for script/api_protobuf/api_protobuf.py generator logic.
ci-api-proto.yml only checks that the committed output matches what the
generator currently produces, so a semantic regression in the generator would
be committed and matched without anything failing. These tests pin the
semantics directly.
"""
from __future__ import annotations
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf"))
from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402
from google.protobuf import descriptor_pb2 # noqa: E402
def _file_with_messages(
*messages: tuple[str, int, bool],
) -> descriptor_pb2.FileDescriptorProto:
"""Build a FileDescriptorProto with one single-field message per entry.
Each entry is (message_name, field_type, deprecated).
"""
file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto")
for name, field_type, deprecated in messages:
msg = file_desc.message_type.add(name=name)
field = msg.field.add(name="value", number=1, type=field_type)
field.options.deprecated = deprecated
return file_desc
UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64
INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64
SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64
UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32
FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64
def test_no_varint64_fields() -> None:
file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False))
assert get_varint64_ifdef(file_desc, {}) == (False, None)
@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64])
def test_single_guard_is_kept(field_type: int) -> None:
file_desc = _file_with_messages(("A", field_type, False))
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X")
def test_two_guards_emit_the_union() -> None:
# The regression this pins: multiple guards used to collapse to
# unconditional, pulling 64-bit varint support into unrelated builds.
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
guards = {"A": "USE_X", "B": "USE_Y"}
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
def test_union_is_sorted_for_deterministic_output() -> None:
file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False))
guards = {"B": "USE_Y", "A": "USE_X"}
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
def test_any_unconditional_message_wins() -> None:
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None)
def test_deprecated_fields_and_messages_are_ignored() -> None:
file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False))
file_desc.message_type[1].options.deprecated = True
assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None)
def test_make_ifdef_line_simple_identifier() -> None:
assert _make_ifdef_line("USE_X") == "#ifdef USE_X"
def test_make_ifdef_line_union_wraps_each_identifier() -> None:
# The second half of the varint64 union guard: compound conditions must
# become #if defined(A) || defined(B), never #ifdef of the raw string.
assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)"
def test_make_ifdef_line_conjunction_and_negation() -> None:
assert (
_make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)"
)
@@ -1,168 +0,0 @@
"""Tests for esphome.components.api.client."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
assert mock_process.called
assert processor.backtrace_state is False
def test_decoder_swallows_platform_handler_error() -> None:
"""The same protection must apply to the platform-specific handler."""
config = {"esphome": {"name": "test"}}
def platform_handler(_config, _line, _state):
raise EsphomeError("no idedata")
processor = api_client._LogLineProcessor(config, platform_handler)
processor.process_line("PC: 0x4010496e")
assert processor.backtrace_state is False
def test_decoder_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
"""
config = {"esphome": {"name": "test"}}
with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()):
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
warnings = [r.message for r in caplog.records if r.levelname == "WARNING"]
assert any("build artifacts not found locally" in m for m in warnings)
assert not any("()" in m for m in warnings)
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to the toolchain; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
processor.process_line("BT1: 0x401049aa")
assert mock_process.call_count == 1
def test_decoder_threads_backtrace_state() -> None:
"""When decoding succeeds, backtrace_state is threaded across calls."""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=[True, False]
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line(">>>stack>>>")
assert processor.backtrace_state is True
processor.process_line("<<<stack<<<")
assert processor.backtrace_state is False
assert not mock_process.call_args_list[0].args[-1]
assert mock_process.call_args_list[1].args[-1]
def test_decoder_uses_platform_handler_when_provided() -> None:
"""The platform handler is preferred over the generic one."""
config = {"esphome": {"name": "test"}}
calls: list[tuple[object, str, bool]] = []
def platform_handler(cfg, line, state):
calls.append((cfg, line, state))
return True
processor = api_client._LogLineProcessor(config, platform_handler)
with patch.object(esp32, "process_stacktrace") as mock_generic:
processor.process_line("BT0: 0x4010496e")
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,239 @@
"""Tests for the MQTT object_id conflict filter.
MQTT still builds default topics and discovery topics from the sanitized
object_id, so entity names that only differ in characters lost during
sanitizing conflict there; _topics_conflict() exempts entities that never
use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85
"""
from pathlib import Path
import pytest
from esphome.components.mqtt import (
_COMMAND_TOPIC_PLATFORMS,
_SUB_TOPIC_PLATFORMS,
_topics_conflict,
)
from esphome.config_validation import Invalid
from esphome.const import (
CONF_COMMAND_TOPIC,
CONF_DISCOVERY,
CONF_NAME,
CONF_STATE_TOPIC,
CONF_TOPIC_PREFIX,
)
from esphome.core import CORE
from esphome.core.entity_helpers import (
entity_duplicate_validator,
validate_no_object_id_conflicts,
)
COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components"
REASON = "mqtt builds default topics from the entity object_id"
# MQTT infrastructure sources, not entity components
_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"}
# The date, time and datetime MQTT components all belong to the datetime platform
_DATETIME_STEMS = {"date", "time", "datetime"}
def test_command_topic_platforms_in_sync() -> None:
"""Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe.
Drift silently reintroduces shared subscribe topics, so this derives the set
from the C++ components that actually call subscribe(); that also catches
platforms like text that subscribe a command topic without exposing a
command_topic key in their schema.
"""
expected: set[str] = set()
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"):
if path.stem in _NON_ENTITY_MQTT_SOURCES:
continue
if "this->subscribe" not in path.read_text(encoding="utf-8"):
continue
stem = path.stem.removeprefix("mqtt_")
expected.add("datetime" if stem in _DATETIME_STEMS else stem)
assert expected == _COMMAND_TOPIC_PLATFORMS
def test_sub_topic_platforms_in_sync() -> None:
"""Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics.
Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra
topics such as position/command from the object_id.
"""
expected = {
path.stem.removeprefix("mqtt_")
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h")
if path.stem != "mqtt_component"
and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8")
}
assert expected == _SUB_TOPIC_PLATFORMS
def test_conflict_filter_exempts_custom_topics() -> None:
"""Test that custom state topics with discovery off avoid the conflict."""
validator = entity_duplicate_validator("sensor")
# Both entities have custom state topics and discovery disabled per entity,
# so no object_id-derived MQTT topic is used
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
# Without the filter the same conflicts are fatal
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
validate_no_object_id_conflicts(REASON)({})
def test_conflict_on_default_command_topic() -> None:
"""Test that commandable platforms conflict through their default command topic.
Custom state topics with discovery off are not enough for platforms that also
subscribe to an object_id-derived command topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
# Both switches share the default command topic: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator(mqtt_config)
# With custom command topics as well, nothing derives from the object_id
CORE.reset()
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
assert component_validator(mqtt_config) is mqtt_config
def test_conflict_on_sub_topic_platforms() -> None:
"""Test that platforms with extra object_id sub-topics always conflict.
Covers derive topics like position/command from the object_id through their
own config keys, so custom state and command topics cannot exempt them.
"""
validator = entity_duplicate_validator("cover")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"})
def test_no_conflict_on_disjoint_default_topics() -> None:
"""Test that entities whose default topics are disjoint do not conflict.
One entity uses only the default command topic and the other only the default
state topic, so they never share a topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
def test_no_conflict_on_empty_topic_prefix() -> None:
"""Test that an empty topic_prefix disables the default topic conflict.
With topic_prefix set to null no default topics exist at runtime, so entities
without custom state topics cannot conflict; only discovery still matters.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
# No default topics and no discovery: valid
config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""}
assert component_validator(config) is config
# Discovery still uses object_id-derived config topics: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""})
@@ -0,0 +1,57 @@
import pytest
from esphome.components.esp32_rmt_led_strip.light import (
CONF_IS_WRGB,
CONF_RGBW_ORDER,
_split_rgbw_order,
_validate_rgbw_order,
_validate_rgbw_order_exclusivity,
)
import esphome.config_validation as cv
from esphome.const import CONF_IS_RGBW
def test_validate_rgbw_order() -> None:
assert _validate_rgbw_order("rwgb") == "RWGB"
@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"])
def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None:
with pytest.raises(cv.Invalid, match="permutation of RGBW"):
_validate_rgbw_order(rgbw_order)
@pytest.mark.parametrize(
("rgbw_order", "expected"),
[
("WRGB", ("RGB", 0)),
("RWGB", ("RGB", 1)),
("GWRB", ("GRB", 1)),
("RGBW", ("RGB", 3)),
],
)
def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None:
assert _split_rgbw_order(rgbw_order) == expected
@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB])
def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
_validate_rgbw_order_exclusivity(
{
CONF_RGBW_ORDER: "RGBW",
CONF_IS_RGBW: conflict == CONF_IS_RGBW,
CONF_IS_WRGB: conflict == CONF_IS_WRGB,
}
)
@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB])
def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None:
config = {
CONF_RGBW_ORDER: "RGBW",
CONF_IS_RGBW: False,
CONF_IS_WRGB: False,
}
config[legacy_option] = False
assert _validate_rgbw_order_exclusivity(config) is config
@@ -179,3 +179,35 @@ def test_process_stacktrace_esp32_crash_handler(
state = process_stacktrace(config, line_mtval_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
def test_process_stacktrace_esp32_foreign_crash(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
"""Crash records from a different firmware build must not be decoded."""
from esphome.components.esp32 import process_stacktrace
config = {"name": "test"}
line_note = (
"[E][esp32.crash:390]: Captured by a different firmware build; "
"addresses belong to that build's ELF"
)
state = process_stacktrace(config, line_note, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
# Lowercase labels are deliberately not matched by any decoder regex,
# since symbols would come from the wrong ELF
lines_addrs = [
"[E][esp32.crash:391]: pc: 0x400D1234",
"[E][esp32.crash:392]: excvaddr: 0x400D5678",
"[E][esp32.crash:392]: mtval: 0x42001234",
"[E][esp32.crash:393]: bt0: 0x400F19A6",
"[E][esp32.crash:394]: other core (0):",
"[E][esp32.crash:395]: bt15: 0x42005ABC",
]
for line in lines_addrs:
state = process_stacktrace(config, line, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
+36 -2
View File
@@ -2,7 +2,8 @@
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components import bk72xx, ln882x, rtl87xx
from esphome.components.libretiny import BASE_SCHEMA, _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
@@ -11,7 +12,7 @@ from esphome.components.libretiny.const import (
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
from esphome.core import CORE, KEY_CORE
@pytest.fixture
@@ -50,3 +51,36 @@ def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> No
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
def test_platform_schemas_are_isolated_instances() -> None:
"""Each LibreTiny platform must own its CONFIG_SCHEMA instance.
BASE_SCHEMA is shared; every platform prepends its own _set_core_data
extra. On the shared object, importing two platform modules in one process
made either platform's validation run both extras, so the wrong platform's
component data won and known boards failed to resolve.
"""
platforms = (bk72xx, ln882x, rtl87xx)
schemas = [platform.CONFIG_SCHEMA for platform in platforms]
assert len({id(schema) for schema in (BASE_SCHEMA, *schemas)}) == 4
# The shared base must not have accumulated any platform's extra.
# prepend_extra wraps validators in _Schema, so unwrap before comparing.
base_extras = [extra.schema for extra in BASE_SCHEMA._extra_schemas]
for platform in platforms:
assert platform._set_core_data not in base_extras
def test_each_platform_resolves_its_own_boards() -> None:
"""Validating one platform's config must leave that platform's component
data in CORE.data. On the shared schema, the last-imported platform's
_set_core_data won for every platform, so known boards failed to resolve
with "This board is unknown"."""
CORE.data[KEY_CORE] = {} # written by the schema's _update_core_data extra
for platform, board in (
(ln882x, "generic-ln882h"),
(bk72xx, "generic-bk7252"),
(rtl87xx, "generic-rtl8720cf-2mb-896k"),
):
platform.CONFIG_SCHEMA({CONF_BOARD: board})
assert CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] is platform.COMPONENT_DATA
@@ -8,7 +8,11 @@ import textwrap
import pytest
from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins
from esphome.components.rp2.generate_boards import (
generate,
load_boards,
parse_variant_pins,
)
PICO_PINS_HEADER = textwrap.dedent("""\
#pragma once
@@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None:
assert boards["rpipico"]["name"] == "Raspberry Pi Pico"
assert boards["rpipico"]["mcu"] == "rp2040"
assert boards["rpipico"]["max_pin"] == 29
# The die key only applies to the RP2350, which ships as more than one die
assert "die" not in boards["rpipico"]
assert "rpipico" in board_pins
assert board_pins["rpipico"]["LED"] == 25
@@ -158,19 +164,195 @@ def test_load_basic_board(arduino_pico: Path) -> None:
def test_load_rp2350_board(arduino_pico: Path) -> None:
"""The Pico 2 uses the RP2350A die, which only exposes GPIO 0-29."""
_add_board(
arduino_pico,
"rpipico2",
mcu="rp2350",
vendor="Raspberry Pi",
name="Pico 2",
pins_header=PICO_PINS_HEADER,
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["rpipico2"]["mcu"] == "rp2350"
assert boards["rpipico2"]["max_pin"] == 47
assert boards["rpipico2"]["max_pin"] == 29
assert boards["rpipico2"]["die"] == "A"
def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None:
"""A variant without PICO_RP2350A cannot be classified; fail loudly."""
_add_board(
arduino_pico,
"no_die_define",
mcu="rp2350",
pins_header=PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="no PICO_RP2350A define"):
load_boards(arduino_pico)
def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None:
"""An unparseable PICO_RP2350A value must not silently widen to B-die."""
_add_board(
arduino_pico,
"hex_die_define",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0x1\n" + PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="unrecognized PICO_RP2350A value"):
load_boards(arduino_pico)
def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None:
"""A third die breaks the "not A means B" reading, so stop rather than guess."""
_add_board(
arduino_pico,
"future_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n"
+ PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="found a PICO_RP2350C define"):
load_boards(arduino_pico)
def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None:
"""PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter."""
_add_board(
arduino_pico,
"revision_define",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n"
+ PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["revision_define"]["die"] == "A"
def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None:
"""Literal forms like (1u) classify the same as bare 1."""
_add_board(
arduino_pico,
"paren_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A (1u)\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["paren_die"]["max_pin"] == 29
assert boards["paren_die"]["die"] == "A"
def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None:
"""A variant declaring the RP2350B die keeps the full GPIO 0-47 range.
The define uses extra whitespace, matching real variant headers.
"""
_add_board(
arduino_pico,
"weact_rp2350b",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0 // RP2350B\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["weact_rp2350b"]["max_pin"] == 47
assert boards["weact_rp2350b"]["die"] == "B"
def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None:
"""Generic boards leave the die a build-time choice; stay permissive.
The permissive range is a fallback, so the die must be recorded as unknown
rather than as the B die.
"""
_add_board(
arduino_pico,
"generic_rp2350",
mcu="rp2350",
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["generic_rp2350"]["max_pin"] == 47
assert boards["generic_rp2350"]["die"] is None
def test_generated_output_records_die(arduino_pico: Path) -> None:
"""The rendered boards.py carries the die on every RP2350 entry."""
_add_board(
arduino_pico,
"rpipico",
pins_header=PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"a_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"b_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"menu_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
)
namespace: dict = {}
exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace)
boards = namespace["BOARDS"]
assert boards["a_die"]["die"] == "A"
assert boards["b_die"]["die"] == "B"
assert boards["menu_die"]["die"] is None
assert "die" not in boards["rpipico"]
def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None:
"""Pin defines beyond the A-die range are dropped from the pin map."""
header = textwrap.dedent("""\
#define PICO_RP2350A 1
#define PIN_LED (25u)
#define PIN_SPI0_MISO (40u)
""")
_add_board(arduino_pico, "a_die", mcu="rp2350", pins_header=header)
board_pins, _ = load_boards(arduino_pico)
assert board_pins["a_die"]["LED"] == 25
assert "MISO" not in board_pins["a_die"]
def test_rp2350a_board_keeps_cyw43_virtual_pins(arduino_pico: Path) -> None:
"""A-die narrowing must not filter CYW43 virtual pins (64-66)."""
_add_board(
arduino_pico,
"rpipico2w",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n" + PICOW_PINS_HEADER,
)
board_pins, boards = load_boards(arduino_pico)
assert boards["rpipico2w"]["max_pin"] == 29
assert boards["rpipico2w"]["max_virtual_pin"] == 64
assert board_pins["rpipico2w"]["LED"] == 64
def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None:
+14
View File
@@ -10,6 +10,7 @@ not be part of a unit test suite.
"""
from collections.abc import Generator
import os
from pathlib import Path
import sys
from unittest.mock import Mock, patch
@@ -40,6 +41,19 @@ def fixture_path() -> Path:
return here / "fixtures"
@pytest.fixture
def probe_env() -> dict[str, str]:
"""Environment for running fixture probe scripts as subprocesses.
Running a script file drops the cwd from sys.path, so prepend the
repo root for the child.
"""
python_path = str(package_root)
if ambient := os.environ.get("PYTHONPATH"):
python_path = os.pathsep.join((python_path, ambient))
return os.environ | {"PYTHONPATH": python_path}
@pytest.fixture
def setup_core(tmp_path: Path) -> Path:
"""Set up CORE with test paths."""
+1 -1
View File
@@ -29,5 +29,5 @@ def load_config_from_fixture(
) -> Config | None:
"""Load configuration from a fixture file."""
fixture_path = fixtures_dir / fixture_name
yaml_content = fixture_path.read_text()
yaml_content = fixture_path.read_text(encoding="utf-8")
return load_config_from_yaml(yaml_file, yaml_content)
+1 -1
View File
@@ -12,7 +12,7 @@ def yaml_file(tmp_path: Path) -> Callable[[str], Path]:
def _yaml_file(content: str) -> Path:
yaml_path = tmp_path / "test.yaml"
yaml_path.write_text(content)
yaml_path.write_text(content, encoding="utf-8")
return yaml_path
return _yaml_file
+9
View File
@@ -1242,6 +1242,15 @@ def test_make_app_name_cpp_special_chars_escaped() -> None:
None,
"https://github.com/esphome/noise-c.git",
),
# A local file:// source is routed to the repository, not a registry name
# -- including the fewer-than-two-slashes spelling.
(
"TeslaBLE=file:///config/esphome/lib_dev",
"TeslaBLE",
None,
"file:///config/esphome/lib_dev",
),
("MyLib=file:lib_dev", "MyLib", None, "file:lib_dev"),
],
)
def test_add_library_str(
+185 -215
View File
@@ -1,4 +1,4 @@
"""Test get_base_entity_object_id function matches C++ behavior."""
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
from collections.abc import Callable, Generator
from pathlib import Path
@@ -25,16 +25,17 @@ from esphome.core.entity_helpers import (
_setup_entity_impl,
entity_duplicate_validator,
finalize_entity_strings,
get_base_entity_object_id,
get_base_entity_name,
register_device_class,
register_icon,
register_unit_of_measurement,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
validate_no_object_id_conflicts,
)
from esphome.cpp_generator import MockObj
from esphome.helpers import sanitize, snake_case
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
from .common import load_config_from_fixture
@@ -57,206 +58,26 @@ def restore_core_state() -> Generator[None, None, None]:
CORE.friendly_name = original_friendly_name
def test_with_entity_name() -> None:
"""Test when entity has its own name - should use entity name."""
# Simple name
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name")
== "temperature_sensor"
)
# Even with device name, entity name takes precedence
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
== "temperature_sensor"
)
# Name with special characters
assert (
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
== "temp__________sensor"
)
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
# Already snake_case
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
# Mixed case
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
def test_empty_name_with_device_name() -> None:
"""Test when entity has empty name and is on a sub-device - should use device name."""
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
assert (
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
== "sub_device_1"
)
assert (
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
== "controller_1"
)
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
def test_empty_name_with_friendly_name() -> None:
"""Test when entity has empty name and no device - should use friendly name."""
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
# Special characters in friendly name
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
def test_empty_name_no_friendly_name() -> None:
"""Test when entity has empty name and no friendly name - should use device name."""
# Test with CORE.name set
CORE.name = "device-name"
assert get_base_entity_object_id("", None) == "device-name"
CORE.name = "Test Device"
assert get_base_entity_object_id("", None) == "test_device"
def test_edge_cases() -> None:
"""Test edge cases."""
# Only spaces
assert get_base_entity_object_id(" ", None) == "___"
# Unicode characters (should be replaced)
assert get_base_entity_object_id("Température", None) == "temp_rature"
assert get_base_entity_object_id("测试", None) == "__"
# Empty string with empty friendly name (empty friendly name is treated as None)
# Falls back to CORE.name
CORE.name = "device"
assert get_base_entity_object_id("", "") == "device"
# Very long name (should work fine)
long_name = "a" * 100 + " " + "b" * 100
expected = "a" * 100 + "_" + "b" * 100
assert get_base_entity_object_id(long_name, None) == expected
@pytest.mark.parametrize(
("name", "expected"),
[
("Temperature Sensor", "temperature_sensor"),
("Living Room Light", "living_room_light"),
("Test-Device_123", "test-device_123"),
("Special!@#Chars", "special___chars"),
("UPPERCASE NAME", "uppercase_name"),
("lowercase name", "lowercase_name"),
("Mixed Case Name", "mixed_case_name"),
(" Spaces ", "___spaces___"),
],
)
def test_matches_cpp_helpers(name: str, expected: str) -> None:
"""Test that the logic matches using snake_case and sanitize directly."""
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
assert get_base_entity_object_id(name, None) == expected
def test_empty_name_fallback() -> None:
"""Test empty name handling which falls back to friendly_name or CORE.name."""
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
# Instead it falls back to friendly_name or CORE.name
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
# But our function returns a fallback
CORE.name = "device"
assert get_base_entity_object_id("", None) == "device" # Uses device name
def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix.
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
assert get_base_entity_object_id("", "Test Device") == "test_device"
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
def test_priority_order() -> None:
def test_get_base_entity_name_priority_order() -> None:
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
CORE.name = "core-device"
# 1. Entity name has highest priority
# 1. Entity name has highest priority and is used as-is, no transformations
assert (
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
== "entity_name"
get_base_entity_name("Entity Name", "Friendly Name", "Device Name")
== "Entity Name"
)
assert get_base_entity_name("Température", None) == "Température"
# 2. Device name is next priority (when entity name is empty)
assert (
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
)
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
# 3. Friendly name is next (when entity and device names are empty)
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
# 4. CORE.name is last resort
assert get_base_entity_object_id("", None, None) == "core-device"
@pytest.mark.parametrize(
("name", "friendly_name", "device_name", "expected"),
[
# name, friendly_name, device_name, expected
("Living Room Light", None, None, "living_room_light"),
("", "Kitchen Controller", None, "kitchen_controller"),
(
"",
"ESP32 Device",
"controller_1",
"controller_1",
), # Device name takes precedence
("GPIO2 Button", None, None, "gpio2_button"),
("WiFi Signal", "My Device", None, "wifi_signal"),
("", None, "esp32_node", "esp32_node"),
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
],
)
def test_real_world_examples(
name: str, friendly_name: str | None, device_name: str | None, expected: str
) -> None:
"""Test real-world entity naming scenarios."""
result = get_base_entity_object_id(name, friendly_name, device_name)
assert result == expected
def test_issue_6953_scenarios() -> None:
"""Test specific scenarios from issue #6953."""
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
# The Python code calculates the base, C++ might append MAC suffix dynamically
CORE.name = "device-name"
CORE.friendly_name = "Friendly Device"
# All empty names should resolve to same base
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
# Scenario 2: Empty names on sub-devices
assert (
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
)
assert (
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
)
# Scenario 3: xyz duplicates
assert get_base_entity_object_id("xyz", None) == "xyz"
assert get_base_entity_object_id("xyz", "Device") == "xyz"
# 4. CORE.name is last resort; an empty friendly name falls through to it
assert get_base_entity_name("", None, None) == "core-device"
assert get_base_entity_name("", "") == "core-device"
# Tests for setup_entity function
@@ -515,9 +336,10 @@ def test_entity_duplicate_validator() -> None:
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1)
assert validated1 == config1
assert ("", "sensor", "temperature") in CORE.unique_ids
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
# Check metadata was stored
metadata = CORE.unique_ids[("", "sensor", "temperature")]
metadata = CORE.unique_ids[temperature_key]
assert metadata["name"] == "Temperature"
assert metadata["platform"] == "sensor"
@@ -525,8 +347,9 @@ def test_entity_duplicate_validator() -> None:
config2 = {CONF_NAME: "Humidity"}
validated2 = validator(config2)
assert validated2 == config2
assert ("", "sensor", "humidity") in CORE.unique_ids
metadata2 = CORE.unique_ids[("", "sensor", "humidity")]
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
assert humidity_key in CORE.unique_ids
metadata2 = CORE.unique_ids[humidity_key]
assert metadata2["name"] == "Humidity"
# Duplicate entity should fail
@@ -547,18 +370,19 @@ def test_entity_duplicate_validator_with_devices() -> None:
device2 = ID("device2", type="Device")
# Same name on different devices should pass
name_hash = fnv1_hash_name("Temperature")
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
validated1 = validator(config1)
assert validated1 == config1
assert ("device1", "sensor", "temperature") in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")]
assert ("device1", "sensor", name_hash) in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
assert metadata1["device_id"] == "device1"
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
validated2 = validator(config2)
assert validated2 == config2
assert ("device2", "sensor", "temperature") in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")]
assert ("device2", "sensor", name_hash) in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
assert metadata2["device_id"] == "device2"
# Duplicate on same device should fail
@@ -610,6 +434,33 @@ def test_entity_different_platforms_yaml_validation(
assert result is not None
def test_object_id_conflict_mqtt_yaml_validation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that names sanitizing to the same object_id fail when mqtt is configured."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert (
"mqtt builds default topics and discovery topics from the entity object_id"
in captured.out
)
def test_object_id_conflict_without_mqtt_yaml_validation(
yaml_file: Callable[[str], str],
) -> None:
"""Test that names sanitizing to the same object_id pass without mqtt/prometheus."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR
)
# This should succeed
assert result is not None
def test_entity_duplicate_validator_error_message() -> None:
"""Test that duplicate entity error messages include helpful metadata."""
# Create validator for sensor platform
@@ -668,7 +519,8 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated1 = validator(config1)
assert validated1 == config1
# New format includes device_id (empty string for main device)
assert ("", "sensor", "temperature") in CORE.unique_ids
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
# Internal entity with same name should pass (not added to unique_ids)
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
@@ -676,7 +528,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
assert validated2 == config2
# Internal entity should not be added to unique_ids
# Count how many times the key appears (should still be 1)
count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature"))
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
assert count == 1
# Another internal entity with same name should also pass
@@ -684,7 +536,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated3 = validator(config3)
assert validated3 == config3
# Still only one entry in unique_ids (from the non-internal entity)
count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature"))
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
assert count == 1
# Non-internal entity with same name should fail
@@ -712,30 +564,148 @@ def test_empty_or_null_device_id_on_entity() -> None:
def test_entity_duplicate_validator_non_ascii_names() -> None:
"""Test that non-ASCII names show helpful error messages."""
"""Test that distinct non-ASCII names no longer collide.
These names used to be rejected because both sanitize to only underscores;
the entity key now hashes the raw name so they stay distinct.
"""
# Create validator for binary_sensor platform
validator = entity_duplicate_validator("binary_sensor")
# First Russian sensor should pass
# Both Russian sensors should pass even though they sanitize identically
config1 = {CONF_NAME: "Датчик открытия основного крана"}
validated1 = validator(config1)
assert validated1 == config1
# Second Russian sensor with different text but same ASCII conversion should fail
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
validated2 = validator(config2)
assert validated2 == config2
# An exact duplicate still fails
config3 = {CONF_NAME: "Датчик открытия основного крана"}
with pytest.raises(
Invalid,
match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found",
):
validator(config3)
def test_entity_duplicate_validator_hash_collision() -> None:
"""Test that two different names with the same FNV-1 hash are rejected."""
# Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b
name_a = "Sensor m2CZ"
name_b = "Sensor qCaa"
assert name_a != name_b
assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b)
validator = entity_duplicate_validator("sensor")
config1 = {CONF_NAME: name_a}
validated1 = validator(config1)
assert validated1 == config1
config2 = {CONF_NAME: name_b}
with pytest.raises(
Invalid,
match=re.compile(
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
r"Both convert to ASCII ID: '_______________________________'.*"
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
rf"Duplicate sensor entity with name '{name_b}' found.*"
rf"The names '{name_b}' and '{name_a}' produce the.*"
r"same entity key hash \(0x0ee5ff7b\).*"
r"To fix: Rename one of the entities",
re.DOTALL,
),
):
validator(config2)
def test_object_id_conflicts_rejected_by_component_validator() -> None:
"""Test that object_id conflicts pass entity validation but fail for mqtt/prometheus."""
validator = entity_duplicate_validator("sensor")
# Both names validate fine in general (distinct raw names, distinct keys)
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
# A component that addresses entities by object_id must reject the config
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
with pytest.raises(
Invalid,
match=re.compile(
r"mqtt builds default topics from the entity object_id.*"
r"sensor entities 'Датчик открытия', 'Датчик закрытия' "
r"share the object_id '_______________'.*"
r"To fix: Add unique ASCII characters",
re.DOTALL,
),
):
component_validator({})
def test_object_id_conflicts_skipped_in_testing_mode() -> None:
"""Test that testing_mode skips the conflict check, as used for grouped testing."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
CORE.testing_mode = True
try:
config: dict = {}
assert component_validator(config) is config
finally:
CORE.testing_mode = False
def test_object_id_conflicts_none_recorded() -> None:
"""Test that distinct object_ids produce no conflicts."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature"})
validator({CONF_NAME: "Humidity"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
def test_object_id_conflicts_device_scoped() -> None:
"""Test that the object_id conflict check is scoped per device.
Same-named entities on different sub-devices were accepted before entity keys
moved to raw names, so the check keeps that scope; conflicts within one device
are still reported with the device named in the message.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")})
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")})
component_validator = validate_no_object_id_conflicts(
"prometheus builds metric labels from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
# Two names sanitizing identically on the same sub-device still conflict
validator(
{CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
validator(
{CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
with pytest.raises(
Invalid,
match=re.compile(
r"prometheus builds metric labels.*on device 'device1'", re.DOTALL
),
):
component_validator({})
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
"""Test that identical names don't show the enhanced message."""
# Create validator for sensor platform
@@ -793,7 +763,7 @@ async def test_setup_entity_empty_name_with_device(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -822,7 +792,7 @@ async def test_setup_entity_empty_name_with_mac_suffix(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -852,7 +822,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -883,7 +853,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
def test_register_string_overflow() -> None:
@@ -0,0 +1,22 @@
esphome:
name: test-object-id-conflict
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
mqtt:
broker: test.mosquitto.org
sensor:
# Distinct raw names are fine in general, but both sanitize to the same
# object_id, which MQTT still uses to build default topics - should fail
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
@@ -0,0 +1,15 @@
esphome:
name: test-object-id-ok
esp32:
board: esp32dev
sensor:
# Distinct raw names that sanitize to the same object_id are allowed when no
# component addresses entities by object_id (no mqtt or prometheus configured)
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
@@ -0,0 +1,11 @@
"""Leave a partial line behind and then close the stream under the runner.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining
cannot work here; the point is that the failure is reported rather than
raised out of the runner's cleanup, where it would bury the exit code.
"""
import sys
sys.stdout.write("partial before close")
sys.stdout.close()
@@ -0,0 +1,11 @@
"""Die part way through a line, the way a build that blows up does.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
message has no trailing newline, so the runner's shim is holding it when
the process exits; nothing else will ever come to release it.
"""
import sys
sys.stdout.write("FATAL: ld returned 1 exit status")
sys.exit(2)
@@ -0,0 +1,15 @@
"""Write a mix of noisy and useful build lines, without flushing.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
runner's shim owns both the filtering and the flushing, so this script
only writes.
"""
import sys
sys.stdout.write("Project build complete.\n")
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("-- Component paths: /a /b /c\n")
sys.stdout.write("[2/9] Building C object\n")
# No terminator, so the shim has to hold this one back.
sys.stdout.write("still going")
@@ -0,0 +1,12 @@
"""Write a form feed part way through the output.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form
feed is not a line terminator here, so everything written must still come
out, including the complete lines that follow it.
"""
import sys
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("page one\x0cpage two\n")
sys.stdout.write("[2/9] Building C object\n")
@@ -0,0 +1,10 @@
"""End on an unterminated line that the filter is supposed to drop.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to
check that releasing a held-back line still applies the filter.
"""
import sys
sys.stdout.write("Compiling main.cpp\n")
sys.stdout.write("Project build complete.")
@@ -0,0 +1,14 @@
"""Print one line, then stay alive so the caller can prove it streamed.
Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The
runner wraps stdout in its filtering shim, so this script deliberately
does not flush: the shim has to do it. The long sleep keeps the process
running, so anything the caller reads must have arrived while the build
was still going rather than at exit.
"""
import sys
import time
sys.stdout.write("Compiling main.cpp\n")
time.sleep(60)
@@ -0,0 +1,19 @@
"""Shared tail for the lazy-import fixture scripts."""
import sys
def print_leaked_modules() -> None:
"""Report argv-listed heavy modules (plus any component package) loaded.
Any component package counts as a leak, not just the ones on the
watch list: executing one drags in codegen/validation machinery by
design.
"""
leaked = [module for module in sys.argv[1:] if module in sys.modules]
leaked += [
module
for module in sys.modules
if module.startswith("esphome.components.") and module not in leaked
]
print(",".join(leaked))
@@ -0,0 +1,27 @@
"""Shared storage-sidecar factory for the lazy-import fixture scripts."""
from esphome.storage_json import StorageJSON
def make_storage() -> StorageJSON:
"""A minimal post-compile esp32 sidecar the upload/logs fast path accepts."""
return StorageJSON(
storage_version=1,
name="test",
friendly_name="Test",
comment=None,
esphome_version="2026.1.0",
src_version=1,
address="1.2.3.4",
web_port=None,
target_platform="ESP32S3",
build_path=None,
firmware_bin_path=None,
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
framework="esp-idf",
core_platform="esp32",
area=None,
framework_version="5.3.1",
)
@@ -0,0 +1,45 @@
"""Run the esptool serial-upload path and report which heavy modules loaded.
Executed as a subprocess by test_lazy_imports.py: heavy module names come
in on argv, the ones found in sys.modules afterwards go out on stdout.
The variant reaches the esptool command line from CORE.data directly; if
someone re-adds the esp32 package import for it, this reports the leak.
"""
import os
import sys
from unittest.mock import patch
from _leak_report import print_leaked_modules
from esphome.__main__ import upload_using_esptool
from esphome.const import (
CONF_ESPHOME,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
)
from esphome.core import CORE
# An ambient ESPHOME_USE_SUBPROCESS would route past the patched
# run_external_command into run_external_process and confuse the checks.
os.environ.pop("ESPHOME_USE_SUBPROCESS", None)
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
with patch("esphome.__main__.run_external_command", return_value=0) as mock_run:
rc = upload_using_esptool(
{CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", "firmware.bin", None
)
# Fail loudly if the upload path stopped doing its work; otherwise an
# empty leak list could just mean nothing ran.
if rc != 0:
sys.exit(f"upload_using_esptool returned {rc}")
cmd = list(mock_run.call_args[0][1:])
if cmd[cmd.index("--chip") + 1] != "esp32s3":
sys.exit(f"variant did not reach the esptool command line: {cmd}")
print_leaked_modules()
@@ -0,0 +1,26 @@
"""Run the esp32 storage fast path and report which heavy modules loaded.
Executed as a subprocess by test_lazy_imports.py: heavy module names come
in on argv, the ones found in sys.modules afterwards go out on stdout.
"""
import sys
from _leak_report import print_leaked_modules
from _storage import make_storage
from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
from esphome.core import CORE, Version
make_storage().apply_to_core()
# Fail loudly if the esp32 fast path stopped doing its work; otherwise an
# empty leak list could just mean nothing ran. Explicit exits rather than
# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them.
esp32_data = CORE.data.get(KEY_ESP32, {})
if esp32_data.get(KEY_VARIANT) != "ESP32S3":
sys.exit(f"apply_to_core did not record the variant: {esp32_data!r}")
if esp32_data.get(KEY_IDF_VERSION) != Version(5, 3, 1):
sys.exit(f"apply_to_core did not parse the framework version: {esp32_data!r}")
print_leaked_modules()
@@ -0,0 +1,100 @@
"""Run the upload command dispatch path and report which heavy modules loaded.
Executed as a subprocess by test_lazy_imports.py: heavy module names come
in on argv, the ones found in sys.modules afterwards go out on stdout.
Covers three fast-path claims: the bundle suffix check in run_esphome reads
BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the
validated-config cache parse stays voluptuous free, and the JSON cache
(lambda sentinel included) resolves without pyyaml or esphome.yaml_util.
"""
import json
import os
from pathlib import Path
import sys
import tempfile
from unittest.mock import patch
from _leak_report import print_leaked_modules
from _storage import make_storage
# Everything imported past this point is the code under test; the pop
# below must only drop what the setup itself preloaded, or it would
# hide modules the dispatch chain pulls in (tarfile has no other guard).
_FIXTURE_PRELOADED = frozenset(sys.modules)
from esphome import __main__ as main_mod # noqa: E402
from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402
CONFIG_TEXT = "esphome:\n name: t\n"
LAMBDA_BODY = 'ESP_LOGD("t", "x");'
# An ambient data-dir override would relocate the storage tree away
# from the tmp config dir this fixture builds.
os.environ.pop("ESPHOME_DATA_DIR", None)
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
with tempfile.TemporaryDirectory() as _td:
tmp = Path(_td)
conf_path = tmp / "test.yaml"
conf_path.write_text(CONFIG_TEXT)
storage_dir = tmp / ".esphome" / "storage"
storage_dir.mkdir(parents=True)
# The cache carries a lambda sentinel so loading revives a real Lambda
# on the fast path. The sidecar is written to the layout
# ext_storage_path resolves once run_esphome sets CORE.config_path;
# going through CORE here would be circular.
cache_path = storage_dir / "test.yaml.validated.json"
cache_path.write_text(
json.dumps(
{
"v": 1,
"esphome": ESPHOME_VERSION,
"config": {
"esphome": {"name": "t"},
"script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}],
},
}
)
)
os.utime(cache_path) # keep the cache at least as fresh as the source
make_storage().save(storage_dir / "test.yaml.json")
dispatched = {}
def fake_upload(args, config):
dispatched["config"] = config
return 0
# This setup pre-imports some watched stdlib modules (tempfile above,
# write_file inside make_storage().save(), unittest.mock -> asyncio ->
# subprocess). Drop exactly those so only a genuine dispatch-time
# re-import is reported; live objects keep their references, so
# cleanup still works. Module-level re-imports are out of reach here
# (esphome.__main__ is already loaded) — the bare-import check in
# test_lazy_imports owns that contract.
for module in sys.argv[1:]:
if module in _FIXTURE_PRELOADED:
sys.modules.pop(module, None)
with patch.dict(main_mod.POST_CONFIG_ACTIONS, {"upload": fake_upload}):
exit_code = main_mod.run_esphome(
["esphome", "upload", str(conf_path), "--device", "192.0.2.1"]
)
# Fail loudly if the fast path didn't do its work; otherwise an empty
# leak list could just mean nothing ran. Explicit exits rather than
# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them.
if exit_code != 0:
sys.exit(f"run_esphome exited {exit_code} before dispatching upload")
config = dispatched.get("config")
if config is None or config.get("esphome") != {"name": "t"}:
sys.exit(f"cache did not resolve through the fast path: {dispatched!r}")
from esphome.core import Lambda
revived = config["script"][0]["lambda"]
if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY:
sys.exit(f"lambda sentinel did not revive: {revived!r}")
print_leaked_modules()
@@ -0,0 +1,21 @@
"""Report whether setup_log() pulled in colorama, then print a colored line.
Executed as a subprocess by test_log.py because module imports are
process-global: the parent prints ``colorama_loaded=True/False`` plus an
ANSI colored line so the caller can observe whether the codes survive to
the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run.
"""
import sys
from esphome.core import CORE
from esphome.log import setup_log
if "--dashboard" in sys.argv:
CORE.dashboard = True
setup_log()
print(f"colorama_loaded={'colorama' in sys.modules}")
print("\033[31mred\033[0m end")
sys.stdout.flush()
+165
View File
@@ -0,0 +1,165 @@
"""Tests for esphome.api_client."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import pytest
from esphome import api_client
from esphome.const import (
CONF_ENCRYPTION,
CONF_KEY,
CONF_PORT,
KEY_CORE,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE
def test_component_shim_reexports_runtime_client() -> None:
"""The old import paths must keep working for external code."""
from esphome.components import api
from esphome.components.api import client as shim
assert shim.run_logs is api_client.run_logs
assert shim.async_run_logs is api_client.async_run_logs
assert api.CONF_ENCRYPTION is CONF_ENCRYPTION
@pytest.mark.asyncio
async def test_async_run_logs_full_flow(caplog) -> None:
"""Drive async_run_logs end to end with a fake connection.
Covers the encryption key extraction, the multi-address banner, the
registry-miss unavailable notice at session start, the on_log
handler, and the stop() cleanup in the finally block.
"""
caplog.set_level("INFO", logger="esphome.api_client")
caplog.set_level("INFO", logger="esphome.platform_hooks")
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"}
config = {
"esphome": {"name": "test"},
"api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}},
}
stop = AsyncMock()
run_started = asyncio.Event()
async def fake_async_run(*args, **kwargs):
run_started.set()
return stop
mock_run = AsyncMock(side_effect=fake_async_run)
printed: list[str] = []
with (
patch.object(api_client, "async_run", mock_run),
patch.object(api_client, "APIClient") as mock_client,
patch.object(api_client, "safe_print", printed.append),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"])
)
# Let the task run up to the forever-wait; the timeout fails the
# test instead of hanging it if the task dies early.
async with asyncio.timeout(1):
await run_started.wait()
on_log = mock_run.call_args.args[1]
on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960"))
# Cancellation is the real termination path; stop() must still run.
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# Both addresses reach APIClient, along with the noise key.
assert mock_client.call_args.kwargs["noise_psk"] == "psk123"
assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"]
assert "1.2.3.4 or 5.6.7.8" in caplog.text
# host has no stacktrace analyzer; the notice fires at session start.
assert "Stacktrace analysis is unavailable" in caplog.text
# The log message was printed with a timestamp prefix.
assert any("hello world" in line for line in printed)
# stop() ran in the finally block despite the cancellation.
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_never_resolves_without_crash_lines() -> None:
"""The headline claim: an ordinary session imports no platform code."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
run_started = asyncio.Event()
async def fake_async_run(*args, **kwargs):
run_started.set()
return stop
mock_run = AsyncMock(side_effect=fake_async_run)
with (
patch.object(api_client, "async_run", mock_run),
patch.object(api_client, "APIClient"),
patch.object(api_client, "safe_print"),
patch("esphome.platform_hooks.get_stacktrace_handler") as mock_resolve,
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"])
)
async with asyncio.timeout(1):
await run_started.wait()
on_log = mock_run.call_args.args[1]
on_log(Mock(message=b"[I][app:100] hello\n[C][wifi:200] connected"))
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_resolve.assert_not_called()
def test_run_logs_suppresses_keyboard_interrupt() -> None:
"""Ctrl-C during log streaming exits cleanly instead of tracebacking."""
with patch.object(
api_client,
"async_run_logs",
AsyncMock(side_effect=KeyboardInterrupt),
) as mock_run:
api_client.run_logs(
{"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False
)
assert mock_run.call_args.kwargs["subscribe_states"] is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps.
That flag is the only thing capping reconnect backoff for a device
that is only briefly awake; dropping it means missed wake windows.
"""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind
# async_run_logs once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
+316
View File
@@ -0,0 +1,316 @@
"""Tests for the async thread helpers."""
from __future__ import annotations
import asyncio
import threading
from typing import Any
from unittest.mock import patch
import pytest
from esphome.async_thread import AsyncDispatchTimeout, AsyncThreadRunner, run_async
def _cleanup_threads() -> set[threading.Thread]:
"""Return the currently live orphan-cleanup threads."""
return {t for t in threading.enumerate() if t.name == "async-orphan-cleanup"}
def _join_new_cleanup_threads(before: set[threading.Thread]) -> None:
"""Wait for cleanup threads spawned since ``before`` to finish."""
for thread in _cleanup_threads() - before:
thread.join(5)
assert not thread.is_alive()
def test_run_async_returns_result() -> None:
"""The coroutine's result is returned to the sync caller."""
async def coro() -> int:
await asyncio.sleep(0)
return 42
assert run_async(coro) == 42
def test_run_async_propagates_exception() -> None:
"""Exceptions raised by the coroutine surface in the caller."""
async def coro() -> None:
raise ValueError("boom")
with pytest.raises(ValueError, match="boom"):
run_async(coro)
def test_run_async_propagates_base_exception() -> None:
"""A BaseException from the coroutine surfaces instead of a None result."""
class Boom(BaseException):
pass
async def coro() -> None:
raise Boom
with pytest.raises(Boom):
run_async(coro)
def test_run_async_timeout() -> None:
"""A coroutine that does not finish in time raises TimeoutError."""
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.05)
# Unblock the abandoned runner so its cleanup thread exits promptly.
release.set()
_join_new_cleanup_threads(before)
def test_run_async_surfaces_loop_startup_failure() -> None:
"""A failure before the coroutine runs raises instead of hanging."""
def failing_run(main: Any) -> None:
# Close the never-awaited coroutine so the test does not leave a
# RuntimeWarning attributed to whatever module GC runs in later.
main.close()
raise OSError("no fds for the event loop")
with (
patch("esphome.async_thread.asyncio.run", side_effect=failing_run),
pytest.raises(OSError, match="no fds"),
):
run_async(lambda: asyncio.sleep(0), timeout=5)
def test_run_preserves_result_when_cleanup_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A loop-cleanup failure after success is logged, not raised."""
async def coro() -> str:
return "ok"
runner: AsyncThreadRunner[str] = AsyncThreadRunner(coro)
def fake_run(main: Any) -> None:
main.close()
# Emulate _runner delivering the result before cleanup raised. A
# None result must count as delivered too, hence the completed flag.
runner.result = "ok"
runner.completed = True
raise KeyboardInterrupt
with (
caplog.at_level("DEBUG", logger="esphome.async_thread"),
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
):
runner.run()
assert runner.event.is_set()
assert runner.exception is None
assert runner.result == "ok"
assert "teardown failed after outcome recorded" in caplog.text
def test_run_async_none_result_is_success() -> None:
"""A coroutine legitimately returning None is not treated as a failure."""
async def coro() -> None:
return None
assert run_async(coro) is None
def test_run_async_on_orphan_skips_none_result() -> None:
"""A late None result completes cleanly without invoking on_orphan."""
orphaned: list[Any] = []
finished = threading.Event()
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
finished.set()
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
release.set()
assert finished.wait(5)
_join_new_cleanup_threads(before)
assert not orphaned
def test_late_failure_without_on_orphan_is_logged(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An abandoned thread's real error leaves a visible trace."""
release = threading.Event()
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
raise ValueError("the real cause")
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
assert "Abandoned async operation failed" in caplog.text
assert "the real cause" in caplog.text
def test_run_async_on_orphan_failure_is_contained(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An on_orphan callback that raises is logged, not propagated."""
released = threading.Event()
release = threading.Event()
def on_orphan(result: str) -> None:
released.set()
raise OSError("close failed")
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "late result"
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=on_orphan)
release.set()
assert released.wait(5)
_join_new_cleanup_threads(before)
assert "Error releasing orphaned result" in caplog.text
def test_run_async_on_orphan_releases_late_result() -> None:
"""A result produced after the timeout is handed to on_orphan."""
orphaned: list[Any] = []
delivered = threading.Event()
release = threading.Event()
def on_orphan(result: str) -> None:
orphaned.append(result)
delivered.set()
async def coro() -> str:
# Block until the test has observed the timeout, so the result is
# guaranteed to arrive late no matter how slowly the runner is
# scheduled.
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "late result"
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=on_orphan)
release.set()
assert delivered.wait(5)
_join_new_cleanup_threads(before)
assert orphaned == ["late result"]
def test_run_async_on_orphan_skips_late_failure() -> None:
"""A late failure after the timeout is not handed to on_orphan."""
orphaned: list[Any] = []
failed = threading.Event()
release = threading.Event()
async def coro() -> str:
# Block until the test has observed the timeout, so the failure is
# guaranteed to arrive late.
await asyncio.get_running_loop().run_in_executor(None, release.wait)
failed.set()
raise ValueError("late failure")
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
release.set()
assert failed.wait(5)
_join_new_cleanup_threads(before)
assert not orphaned
def test_run_async_detects_missing_outcome() -> None:
"""A run that records neither result nor exception raises loudly."""
def fake_run(main: Any) -> None:
# Simulate a loop that silently dropped the coroutine.
main.close()
with (
patch("esphome.async_thread.asyncio.run", side_effect=fake_run),
pytest.raises(RuntimeError, match="without a result"),
):
run_async(lambda: asyncio.sleep(0), timeout=5)
def test_run_async_raises_distinguishable_timeout() -> None:
"""The dispatcher's own expiry is a distinct TimeoutError subclass."""
release = threading.Event()
async def coro() -> None:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
before = _cleanup_threads()
with pytest.raises(AsyncDispatchTimeout):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
def test_orphan_watcher_gives_up_on_a_hung_coroutine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The watcher exits after its bound instead of parking forever."""
from esphome import async_thread
monkeypatch.setattr(async_thread, "ORPHAN_WAIT_TIMEOUT", 0.01)
release = threading.Event()
orphaned: list[Any] = []
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "too late"
before = _cleanup_threads()
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01, on_orphan=orphaned.append)
_join_new_cleanup_threads(before)
assert not orphaned
release.set()
def test_late_real_result_without_handler_is_logged(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A genuinely dropped late result leaves the discard trace."""
release = threading.Event()
async def coro() -> str:
await asyncio.get_running_loop().run_in_executor(None, release.wait)
return "dropped"
before = _cleanup_threads()
with caplog.at_level("DEBUG", logger="esphome.async_thread"):
with pytest.raises(TimeoutError):
run_async(coro, timeout=0.01)
release.set()
_join_new_cleanup_threads(before)
assert "Discarding late result" in caplog.text
+97 -22
View File
@@ -23,8 +23,8 @@ from esphome.bundle import (
_default_target_dir,
_find_used_secret_keys,
add_bundle_file,
add_secret_scan_dir,
extract_bundle,
is_bundle_path,
prepare_bundle_for_compile,
read_bundle_manifest,
remap_bundle_path,
@@ -98,26 +98,6 @@ def _setup_config_dir(
return config_dir
# ---------------------------------------------------------------------------
# is_bundle_path
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("filename", "expected"),
[
(f"my_device{BUNDLE_EXTENSION}", True),
(f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True),
("my_device.yaml", False),
("my_device.tar.gz", False),
("my_device.zip", False),
("", False),
],
)
def test_is_bundle_path(filename: str, expected: bool) -> None:
assert is_bundle_path(Path(filename)) is expected
# ---------------------------------------------------------------------------
# _default_target_dir
# ---------------------------------------------------------------------------
@@ -1248,7 +1228,8 @@ def test_discover_files_deeply_nested_include(tmp_path: Path) -> None:
def test_discover_files_nested_include_unresolved_substitution(
tmp_path: Path,
) -> None:
"""!include with substitution vars in path cannot be resolved; skipped gracefully."""
"""!include with substitution vars in path but no candidate files on disk
(the glob's only match is the config itself) is skipped gracefully."""
config_dir = _setup_config_dir(tmp_path)
(config_dir / "test.yaml").write_text(
"esphome:\n name: test\nwifi: !include ${platform}.yaml\n"
@@ -1262,6 +1243,62 @@ def test_discover_files_nested_include_unresolved_substitution(
assert "test.yaml" in paths
def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None:
"""The issue-17650 layout: templated package includes chain through a glob
candidate into a Jinja conditional whose ``../`` branch is bundled."""
config_dir = _setup_config_dir(
tmp_path,
files={
"includes/esp-basics.yaml": (
"packages:\n"
" - !include boards/${board}.yaml\n"
" - !include keys/${system_name}.yaml\n"
),
"includes/boards/wemos-d1-mini.yaml": (
'packages:\n - !include ${ "NO BT.yaml" if bt else "../empty.yaml" }\n'
),
"includes/keys/device-a.yaml": "api:\n",
"includes/keys/device-b.yaml": "api:\n",
"includes/empty.yaml": "{}\n",
},
)
(config_dir / "test.yaml").write_text(
"esphome:\n name: test\npackages:\n - !include includes/esp-basics.yaml\n"
)
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "includes/esp-basics.yaml" in paths
assert "includes/boards/wemos-d1-mini.yaml" in paths
assert "includes/keys/device-a.yaml" in paths
assert "includes/keys/device-b.yaml" in paths
assert "includes/empty.yaml" in paths
def test_discover_files_candidate_outside_config_dir_skipped(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A candidate branch resolving above the config dir is not bundled."""
config_dir = _setup_config_dir(tmp_path)
(tmp_path / "outside.yaml").write_text("api:\n")
(config_dir / "test.yaml").write_text(
"esphome:\n name: test\n"
'wifi: !include ${ "a.yaml" if x else "../outside.yaml" }\n'
)
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert not any("outside" in p for p in paths)
assert any(
"outside config directory" in r.message and "outside.yaml" in r.message
for r in caplog.records
)
def test_discover_files_nested_include_load_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -1594,6 +1631,44 @@ def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None:
assert "unused" not in secrets_data
def test_create_bundle_scans_remote_package_files_for_secrets(tmp_path: Path) -> None:
"""Secrets referenced only by git-fetched package files must be shipped
in the filtered secrets.yaml (regression test for issue 18023)."""
config_dir = _setup_config_dir(tmp_path)
secrets = config_dir / "secrets.yaml"
secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n")
# Simulate a git-fetched package checkout referencing a secret
repo_dir = config_dir / ".esphome" / "packages" / "6bcd6aa8"
package_dir = repo_dir / "packages"
package_dir.mkdir(parents=True)
(package_dir / "base.yml").write_text(
"ota:\n - platform: esphome\n password: !secret ota_password\n"
)
# References inside hidden directories such as .git must not be scanned
hidden_dir = repo_dir / ".git"
hidden_dir.mkdir()
(hidden_dir / "leak.yaml").write_text("password: !secret unused\n")
add_secret_scan_dir(repo_dir)
creator = ConfigBundleCreator({})
result = creator.create_bundle()
assert result.manifest[ManifestKey.HAS_SECRETS] is True
buf = io.BytesIO(result.data)
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
secrets_data = tar.extractfile("secrets.yaml").read().decode()
names = tar.getnames()
assert "ota_password" in secrets_data
assert "hunter2" in secrets_data
assert "unused" not in secrets_data
# The package checkout itself must not be bundled
assert not any("base.yml" in name for name in names)
def test_create_bundle_no_secrets(tmp_path: Path) -> None:
_setup_config_dir(tmp_path)
+291 -52
View File
@@ -2,15 +2,20 @@
from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network
import json
import os
from pathlib import Path
from typing import Any
from unittest.mock import patch
from uuid import UUID
import pytest
from esphome import const, yaml_util
from esphome.__main__ import run_esphome
from esphome.compiled_config import (
_LAMBDA_KEY,
compiled_config_path,
load_compiled_config,
save_compiled_config,
@@ -20,32 +25,30 @@ from esphome.const import (
CONF_ESPHOME,
CONF_NAME,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
Toolchain,
)
from esphome.core import CORE
from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds
from esphome.util import OrderedDict
_VALIDATED_CONFIG_YAML = """\
esphome:
name: lite_test
friendly_name: Lite Test Device
esp32:
board: nodemcu-32s
logger:
baud_rate: 115200
api:
port: 6053
encryption:
key: 6dGhpcyBpcyBhIHRlc3Q=
ota:
- platform: esphome
port: 3232
password: secret
wifi:
ssid: ssid
use_address: 192.168.1.42
"""
_VALIDATED_CONFIG = {
"esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"},
"esp32": {"board": "nodemcu-32s"},
"logger": {"baud_rate": 115200},
"api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}},
"ota": [{"platform": "esphome", "port": 3232, "password": "secret"}],
"wifi": {"ssid": "ssid", "use_address": "192.168.1.42"},
}
def _cache_body(config: dict | None = None) -> str:
"""Render the JSON envelope the production save writes."""
return json.dumps(
{"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG}
)
def _write_storage(
@@ -77,10 +80,10 @@ def _write_storage(
storage_path.write_text(json.dumps(data), encoding="utf-8")
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
def _write_cache(cache_path: Path, body: str | None = None) -> Path:
"""Write the cache file and return it."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8")
return cache_path
@@ -94,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
@pytest.fixture
def fresh_cache_files(tmp_path: Path) -> Path:
"""YAML + StorageJSON + cache, all consistent and fresh."""
def primed_storage(tmp_path: Path) -> Path:
"""YAML + StorageJSON sidecar, no cache yet."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
_write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json")
return yaml_path
@pytest.fixture
def fresh_cache_files(primed_storage: Path) -> Path:
"""YAML + StorageJSON + cache, all consistent and fresh."""
storage_dir = primed_storage.parent / ".esphome" / "storage"
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache, primed_storage, offset=5)
return primed_storage
def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
"""The cache file shape is predictable from the YAML filename."""
path = compiled_config_path("device.yaml")
assert path.name == "device.yaml.validated.yaml"
assert path.name == "device.yaml.validated.json"
assert path.parent.name == "storage"
@@ -124,28 +131,27 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
assert config["ota"][0]["password"] == "secret"
# The fast path loads plain scalars; no per-node source ranges exist.
assert type(config[CONF_ESPHOME][CONF_NAME]) is str
# apply_to_core populated exactly what upload/logs read off CORE.
assert CORE.name == "lite_test"
assert CORE.build_path == Path("/build/lite_test")
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
from esphome.components.esp32.const import KEY_ESP32
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
@@ -156,8 +162,6 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms(
tmp_path: Path,
) -> None:
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
@@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms(
esp_platform="ESP8266",
core_platform="esp8266",
)
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
@@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
cache_path = storage_dir / "lite_test.yaml.validated.yaml"
cache_path = storage_dir / "lite_test.yaml.validated.json"
sidecar_path = storage_dir / "lite_test.yaml.json"
if scenario == "missing_cache":
@@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
elif scenario == "corrupt_cache":
_write_storage(sidecar_path)
_set_cache_mtime(
_write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5
_write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5
)
elif scenario == "missing_sidecar":
# Cache fresh + parseable, but no StorageJSON → can't populate CORE.
@@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
assert load_compiled_config(yaml_path) is None
@pytest.mark.parametrize(
"body",
[
pytest.param(
json.dumps(
{"v": 999, "esphome": const.__version__, "config": {"esphome": {}}}
),
id="wrong_version",
),
pytest.param(
json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}),
id="missing_version",
),
pytest.param(
json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}),
id="other_esphome_version",
),
pytest.param(
json.dumps({"v": 1, "config": {"esphome": {}}}),
id="missing_esphome_version",
),
pytest.param(
json.dumps(
{
"v": 1,
"esphome": const.__version__,
"config": ["not", "a", "dict"],
}
),
id="non_dict_config",
),
pytest.param(
json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config"
),
pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"),
],
)
def test_load_compiled_config_rejects_bad_envelope(
primed_storage: Path, body: str
) -> None:
"""A foreign or future cache shape falls back instead of half-loading."""
storage_dir = primed_storage.parent / ".esphome" / "storage"
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body)
_set_cache_mtime(cache, primed_storage, offset=5)
assert load_compiled_config(primed_storage) is None
def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None:
"""A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path."""
storage_dir = primed_storage.parent / ".esphome" / "storage"
legacy = _write_cache(
storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n"
)
_set_cache_mtime(legacy, primed_storage, offset=5)
assert load_compiled_config(primed_storage) is None
def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None:
"""A successful save leaves only the JSON cache behind."""
CORE.config_path = tmp_path / "lite_test.yaml"
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text("esphome:\n name: lite_test\n")
save_compiled_config({"esphome": {"name": "lite_test"}})
assert compiled_config_path("lite_test.yaml").is_file()
assert not legacy.exists()
def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None:
"""The secret-bearing legacy cache goes away regardless of write outcome."""
CORE.config_path = tmp_path / "lite_test.yaml"
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text("esphome:\n name: lite_test\n")
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
save_compiled_config({"esphome": {"name": "lite_test"}})
assert not legacy.exists()
assert not compiled_config_path("lite_test.yaml").exists()
def test_save_warns_when_legacy_cache_unremovable(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A secret-bearing legacy file that won't unlink warns; the write proceeds."""
CORE.config_path = tmp_path / "lite_test.yaml"
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.mkdir() # unlink() on a directory raises OSError
with caplog.at_level("WARNING", logger="esphome.compiled_config"):
save_compiled_config({"esphome": {"name": "lite_test"}})
assert "legacy validated-config cache" in caplog.text
assert compiled_config_path("lite_test.yaml").is_file()
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
command: str,
@@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
) -> None:
"""Without a StorageJSON sidecar (no compile has run), the fallback
skips the cache write -- load_compiled_config requires the sidecar,
so writing the rendered (secret-resolved) YAML would be inert and
so writing the rendered (secret-resolved) config would be inert and
leak secrets to disk for nothing."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
@@ -293,7 +399,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
@@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None
def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
"""`save_compiled_config` writes the dumped YAML next to the sidecar."""
"""`save_compiled_config` writes the JSON envelope next to the sidecar."""
CORE.config_path = tmp_path / "lite_test.yaml"
save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})
cache_path = compiled_config_path("lite_test.yaml")
assert cache_path.is_file()
body = cache_path.read_text()
assert "name: lite_test" in body
assert "logger:" in body
envelope = json.loads(cache_path.read_text())
assert envelope["v"] == 1
assert envelope["esphome"] == const.__version__
assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}}
def test_save_compiled_config_swallows_dump_errors(
def test_save_compiled_config_swallows_write_errors(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Failures during the dump are non-fatal -- a bad cache just means
"""Failures during the write are non-fatal -- a bad cache just means
the next fast path falls back to read_config()."""
CORE.config_path = tmp_path / "lite_test.yaml"
with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")):
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
save_compiled_config({"esphome": {"name": "lite_test"}})
assert not compiled_config_path("lite_test.yaml").exists()
def test_save_stringifies_unknown_values(tmp_path: Path) -> None:
"""A type with no dedicated encoding stores its string form."""
class Weird:
def __str__(self) -> str:
return "weird-str"
CORE.config_path = tmp_path / "lite_test.yaml"
save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}})
envelope = json.loads(compiled_config_path("lite_test.yaml").read_text())
assert envelope["config"]["esphome"]["weird"] == "weird-str"
def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None:
"""A non-basic dict key aborts the write; the fast path falls back."""
CORE.config_path = tmp_path / "lite_test.yaml"
save_compiled_config({"esphome": {("a", "b"): "lite_test"}})
assert not compiled_config_path("lite_test.yaml").exists()
def _normalize(value: Any) -> Any:
"""Make Lambda comparable; everything else compares by value already."""
if isinstance(value, Lambda):
return ("__lambda__", value.value)
if isinstance(value, dict):
return {k: _normalize(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize(v) for v in value]
return value
def _round_trip_config() -> OrderedDict:
"""A post-validation shaped config exercising every representer type."""
return OrderedDict(
{
"esphome": OrderedDict(
{
"name": "lite_test",
"build_path": Path("/build/lite_test"),
"on_boot": [
OrderedDict(
{
"trigger_id": ID("trigger_1", type="Trigger"),
"then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}],
}
)
],
}
),
"wifi": OrderedDict(
{
"id": ID("wifi_id", type="WiFiComponent"),
"reboot_timeout": TimePeriodMilliseconds(milliseconds=900000),
"use_address": IPv4Address("192.168.1.42"),
"subnet": IPv4Network("192.168.1.0/24"),
"mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01),
}
),
"misc": OrderedDict(
{
"uuid": UUID("12345678-1234-5678-1234-567812345678"),
"toolchain": Toolchain.PLATFORMIO,
"hex": HexInt(0x1234),
"levels": (1, 2.5, True, None),
"empty": {},
}
),
}
)
def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None:
"""The JSON cache loads the same tree the YAML cache used to."""
config = _round_trip_config()
save_compiled_config(config)
from_json = load_compiled_config(primed_storage)
assert from_json is not None
yaml_cache = primed_storage.parent / "dumped.yaml"
yaml_cache.write_text(yaml_util.dump(config, show_secrets=True))
from_yaml = yaml_util.load_yaml(
yaml_cache, clear_secrets=False, track_document_range=False
)
assert _normalize(from_json) == _normalize(from_yaml)
def test_lambda_sentinel_round_trips(primed_storage: Path) -> None:
"""A !lambda body comes back as a Lambda with the same source."""
body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";'
save_compiled_config(
{
"esphome": {"name": "lite_test"},
"script": [{"then": [{"lambda": Lambda(body)}]}],
}
)
config = load_compiled_config(primed_storage)
assert config is not None
revived = config["script"][0]["then"][0]["lambda"]
assert isinstance(revived, Lambda)
assert revived.value == body
def test_object_hook_requires_exact_shape(primed_storage: Path) -> None:
"""Only the exact one-key string-valued sentinel revives a Lambda."""
storage_dir = primed_storage.parent / ".esphome" / "storage"
config = {
"esphome": {"name": "lite_test"},
"extra_key": {_LAMBDA_KEY: "x", "y": 1},
"non_str": {_LAMBDA_KEY: 5},
}
cache = _write_cache(
storage_dir / "lite_test.yaml.validated.json", _cache_body(config)
)
_set_cache_mtime(cache, primed_storage, offset=5)
loaded = load_compiled_config(primed_storage)
assert loaded is not None
assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1}
assert loaded["non_str"] == {_LAMBDA_KEY: 5}
def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
"""Non-str basic keys stringify; validated configs only use string keys."""
save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}})
config = load_compiled_config(primed_storage)
assert config is not None
assert config["table"] == {"1": "a", "2": "b"}
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
can't drive upload/logs, so the fast path falls back."""
@@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
'"framework": null, "core_platform": null}'
)
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
_set_cache_mtime(cache_path, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is None
+16 -1
View File
@@ -3,7 +3,13 @@
from collections.abc import Callable
from unittest.mock import patch
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
import pytest
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
)
from esphome.const import (
CONF_LEVEL,
CONF_LOGGER,
@@ -133,3 +139,12 @@ def test_get_logger_level() -> None:
mock_config = {CONF_LOGGER: {}}
with patch("esphome.config_helpers.CORE.config", mock_config):
assert get_logger_level() == "DEBUG"
def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
assert frameworks_for_platforms(["esp32"]) == {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
}
with pytest.raises(ValueError, match="unknown platform"):
frameworks_for_platforms(["esp32", "not_a_platform"])
+103 -1
View File
@@ -2,6 +2,7 @@ import json
import logging
from pathlib import Path
import string
from unittest.mock import patch
from hypothesis import example, given, settings
from hypothesis.strategies import builds, integers, ip_addresses, one_of, text
@@ -931,7 +932,7 @@ def test_string_no_slash__slash_replaced_with_warning(
actual = cv.string_no_slash(value)
assert actual == expected
assert "reserved as a URL path separator" in caplog.text
assert "will become an error in ESPHome 2026.7.0" in caplog.text
assert "will become an error in ESPHome 2027.7.0" in caplog.text
def test_string_no_slash__long_string_allowed() -> None:
@@ -2428,6 +2429,58 @@ def test_one_of_string_and_space() -> None:
assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b"
def test_one_of_string_and_underscore() -> None:
assert cv.one_of("a-b", string=True, underscore="-")("a_b") == "a-b"
assert cv.one_of("a-b", string=True, underscore="-")("a-b") == "a-b"
def test_one_of_string_lower_space_and_underscore() -> None:
validator = cv.one_of("output-mode", lower=True, space="-", underscore="-")
assert validator("output_mode") == "output-mode"
assert validator("OUTPUT_MODE") == "output-mode"
assert validator("output mode") == "output-mode"
assert validator("output-mode") == "output-mode"
def test_one_of_string_underscore_unknown() -> None:
with pytest.raises(Invalid):
cv.one_of("a-b", string=True, underscore="-")("c_d")
def test_one_of_string_underscore_default_unchanged() -> None:
with pytest.raises(Invalid):
cv.one_of("a-b", string=True)("a_b")
def test_one_of_string_and_hyphen() -> None:
assert cv.one_of("a_b", string=True, hyphen="_")("a-b") == "a_b"
assert cv.one_of("a_b", string=True, hyphen="_")("a_b") == "a_b"
def test_one_of_string_lower_space_and_hyphen() -> None:
validator = cv.one_of("output_mode", lower=True, space="_", hyphen="_")
assert validator("output-mode") == "output_mode"
assert validator("OUTPUT-MODE") == "output_mode"
assert validator("output mode") == "output_mode"
assert validator("output_mode") == "output_mode"
def test_one_of_string_hyphen_unknown() -> None:
with pytest.raises(Invalid):
cv.one_of("a_b", string=True, hyphen="_")("c-d")
def test_one_of_string_hyphen_default_unchanged() -> None:
with pytest.raises(Invalid):
cv.one_of("a_b", string=True)("a-b")
def test_one_of_string_underscore_hyphen_swap_no_cascade() -> None:
validator = cv.one_of("a-b", "a_b", string=True, underscore="-", hyphen="_")
assert validator("a_b") == "a-b"
assert validator("a-b") == "a_b"
def test_one_of_int() -> None:
assert cv.one_of(1, 2, int=True)("2") == 2
@@ -2466,6 +2519,20 @@ def test_enum_valid() -> None:
assert result.enum_value == 10
def test_enum_valid_with_underscore() -> None:
mapping = {"a-b": 1}
result = cv.enum(mapping, string=True, underscore="-")("a_b")
assert result == "a-b"
assert result.enum_value == 1
def test_enum_valid_with_hyphen() -> None:
mapping = {"a_b": 1}
result = cv.enum(mapping, string=True, hyphen="_")("a-b")
assert result == "a_b"
assert result.enum_value == 1
# ---------------------------------------------------------------------------
# lambda_ / returning_lambda
# ---------------------------------------------------------------------------
@@ -2860,11 +2927,46 @@ def test_require_esphome_version_ok() -> None:
assert cv.require_esphome_version(1, 0, 0)("test") == "test"
def test_require_esphome_version_accepts_version_object() -> None:
"""The Version form matches require_framework_version's style."""
assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test"
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
cv.require_esphome_version(cv.Version(9999, 0, 0))("test")
def test_require_esphome_version_partial_ints_fail_at_call_site() -> None:
"""Missing ints raise immediately instead of a TypeError inside the validator."""
with pytest.raises(ValueError, match="needs a Version or"):
cv.require_esphome_version(2026, 8)
with pytest.raises(ValueError, match="needs a Version or"):
cv.require_esphome_version(2026)
def test_require_esphome_version_too_old() -> None:
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
cv.require_esphome_version(9999, 0, 0)("test")
@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"])
def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None:
"""A dev or beta build of the required version satisfies it.
Pins the behavior of the old tuple comparison that dropped the
suffix, now expressed through Version ordering where the extra field
only breaks ties upward.
"""
with patch.object(cv, "ESPHOME_VERSION", current):
assert cv.require_esphome_version(2026, 8, 0)("test") == "test"
def test_require_esphome_version_older_prerelease_fails() -> None:
with (
patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"),
pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"),
):
cv.require_esphome_version(2026, 8, 0)("test")
# ---------------------------------------------------------------------------
# suppress_invalid / validate_source_shorthand / rename_key
# ---------------------------------------------------------------------------
+27
View File
@@ -1,5 +1,7 @@
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
from hypothesis import given
@@ -213,6 +215,31 @@ class TestLambda:
assert str(target) is value.value
def test_init__expression_initializer(self):
from esphome.cpp_generator import RawExpression
target = core.Lambda(RawExpression("foo()"))
assert target.value == "foo();"
def test_init__other_initializer(self):
target = core.Lambda(123)
assert target.value == 123
def test_init_from_str_does_not_import_codegen(self):
"""The validated-config cache revives Lambdas on the upload fast path."""
# sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it.
check = (
"import sys; from esphome.core import Lambda; "
"Lambda('return 1;'); "
"sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)"
)
result = subprocess.run(
[sys.executable, "-c", check], capture_output=True, text=True, check=False
)
assert result.returncode == 0, result.stderr
def test_parts(self):
target = core.Lambda(SAMPLE_LAMBDA.strip())
+51
View File
@@ -4,6 +4,7 @@ from unittest.mock import Mock
import pytest
from esphome import const, cpp_helpers as ch
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.cpp_helpers import ComponentSourcePool, register_component_source
@@ -167,3 +168,53 @@ def test_register_component_source_overflow_suppressed_in_testing_mode(
idx = register_component_source("overflow_component")
assert idx == 0
assert "Too many unique component source names" not in caplog.text
def _define_value(name: str) -> str | None:
for define in ch.CORE.defines:
if define.name == name:
# Values are codegen expressions (IntLiteral); compare rendered.
return str(define.value)
return None
def test_slot_counter_emits_requested_count() -> None:
"""Each request bumps the count; the self-scheduled FINAL job emits it."""
request = ch.slot_counter("TEST_SLOT_COUNT")
request()
request()
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT") == "2"
def test_slot_counter_without_requests_emits_nothing() -> None:
"""No requests, no job, no define — the guarded storage compiles out."""
ch.slot_counter("TEST_SLOT_COUNT_UNUSED")
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_UNUSED") is None
def test_slot_counter_request_from_final_job_still_emits() -> None:
"""The FIRST request for a define may come from a FINAL job: its emit job
is scheduled mid-drain and flush_tasks() loops until the heap is empty.
Later requests do not get this guarantee — see the companion test."""
request = ch.slot_counter("TEST_SLOT_COUNT_LATE")
@coroutine_with_priority(CoroPriority.FINAL)
async def late_requester() -> None:
request()
ch.CORE.add_job(late_requester)
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_LATE") == "1"
def test_slot_counter_request_after_emit_raises() -> None:
"""The boundary of FINAL-time requests: once the define was emitted, a
further request would silently undersize the storage, so it fails loudly."""
request = ch.slot_counter("TEST_SLOT_COUNT_TOO_LATE")
request()
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1"
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"):
request()
+177 -42
View File
@@ -155,6 +155,62 @@ def test_generate_cmakelists_txt_basic(tmp_component):
assert "main.c" in content
def test_generate_cmakelists_txt_external_source_uses_absolute_paths(
tmp_component, tmp_path
):
# A local library's sources live outside the component dir (source_path),
# so SRCS and INCLUDE_DIRS must be emitted as absolute paths into it.
source = tmp_path / "user_lib"
(source / "src").mkdir(parents=True)
(source / "include").mkdir()
(source / "src" / "thing.cpp").write_text("int t;")
tmp_component.source_path = source
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
abs_src = str((source / "src" / "thing.cpp").resolve()).replace("\\", "/")
abs_inc = str((source / "include").resolve()).replace("\\", "/")
assert abs_src in content
assert abs_inc in content
# Nothing was copied into the component dir.
assert not (tmp_component.path / "src").exists()
def test_generate_cmakelists_txt_external_source_absolutises_link_dirs(
tmp_component, tmp_path
):
# A local library's relative -L path must be made absolute against its own
# directory so it resolves from the component cache dir.
source = tmp_path / "user_lib"
(source / "src").mkdir(parents=True)
(source / "src" / "thing.cpp").write_text("int t;")
(source / "libs").mkdir()
tmp_component.source_path = source
tmp_component.data = {"build": {"flags": ["-Llibs"]}}
content = generate_cmakelists_txt(tmp_component)
abs_lib = str((source / "libs").resolve()).replace("\\", "/")
assert "target_link_directories" in content
assert abs_lib in content
def test_generate_cmakelists_txt_external_source_root_srcdir(tmp_component, tmp_path):
# An external source with files at its root (no src/ or include/ dir):
# the src-dir search falls through to "." and the missing include dirs are
# filtered out.
source = tmp_path / "flat_lib"
source.mkdir()
(source / "thing.cpp").write_text("int t;")
tmp_component.source_path = source
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert str((source / "thing.cpp").resolve()).replace("\\", "/") in content
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
@@ -169,30 +225,57 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
}
content = generate_cmakelists_txt(tmp_component)
sep = "\\\\" if os.name == "nt" else "/"
# Paths are always emitted with forward slashes so the CMakeLists is
# portable; on Windows os.path.relpath would otherwise yield backslashes
# that break CMake's list re-parsing.
assert (
content
== f"""idf_component_register(
SRCS "src{sep}main.c"
== """idf_component_register(
SRCS "src/main.c"
INCLUDE_DIRS "src"
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
REQUIRES dep ${ESPHOME_PROJECT_MANAGED_COMPONENTS} ${ESPHOME_PROJECT_BUILTIN_COMPONENTS}
)
target_compile_options(${{COMPONENT_LIB}} PUBLIC
target_compile_options(${COMPONENT_LIB} PUBLIC
"-DTEST"
)
target_compile_options(${{COMPONENT_LIB}} PRIVATE
target_compile_options(${COMPONENT_LIB} PRIVATE
"-Wall"
)
target_link_directories(${{COMPONENT_LIB}} INTERFACE
target_link_directories(${COMPONENT_LIB} INTERFACE
"lib"
)
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
target_link_libraries(${COMPONENT_LIB} INTERFACE
"mylib"
)
"""
)
def test_generate_cmakelists_txt_uses_forward_slashes_on_windows(
tmp_component, monkeypatch: pytest.MonkeyPatch
) -> None:
# os.path.relpath yields backslash paths on Windows, which CMake rejects
# when it re-parses the SRCS list (e.g. "\b" in "src\backend" is an invalid
# character escape). Simulate that output and confirm the generated
# CMakeLists normalizes the separators to forward slashes.
src_dir = tmp_component.path / "src" / "backend"
src_dir.mkdir(parents=True)
(src_dir / "cipher.c").write_text("int f() {}")
tmp_component.data = {}
monkeypatch.setattr("esphome.espidf.component.os.sep", "\\")
monkeypatch.setattr(
"esphome.espidf.component.os.path.relpath",
lambda *args, **kwargs: "src\\backend\\cipher.c",
)
content = generate_cmakelists_txt(tmp_component)
assert 'SRCS "src/backend/cipher.c"' in content
assert "\\" not in content
def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
# PlatformIO shell-lexes each build.flags entry, so a single entry can
# carry a flag and its argument. The generated CMakeLists must emit them
@@ -435,70 +518,66 @@ empty=
def test_node_key_git_with_ref():
key, is_git, locator = _node_key(
key, kind, locator = _node_key(
"name", None, "https://github.com/foo/bar.git#v1.2.3"
)
assert key == "foo/bar"
assert is_git is True
assert kind == "git"
assert locator == ("https://github.com/foo/bar.git", "v1.2.3")
def test_node_key_git_branch_ref():
key, is_git, locator = _node_key(
key, kind, locator = _node_key(
"name", None, "https://github.com/foo/bar.git#some-branch"
)
assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch")
assert (key, kind, locator[1]) == ("foo/bar", "git", "some-branch")
def test_node_key_git_no_ref():
_key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git")
assert is_git is True
_key, kind, locator = _node_key("name", None, "https://github.com/foo/bar.git")
assert kind == "git"
assert locator == ("https://github.com/foo/bar.git", None)
def test_node_key_url_in_name_is_git():
# add_library("https://github.com/x/y", None): PlatformIO accepted a bare
# git URL as the library name, so the converter must too.
key, is_git, locator = _node_key(
"https://github.com/pstolarz/OneWireNg", None, None
)
key, kind, locator = _node_key("https://github.com/pstolarz/OneWireNg", None, None)
assert key == "pstolarz/OneWireNg"
assert is_git is True
assert kind == "git"
assert locator == ("https://github.com/pstolarz/OneWireNg", None)
def test_node_key_url_in_name_with_ref():
key, is_git, locator = _node_key(
"https://github.com/foo/bar.git#v1.2.3", None, None
)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("https://github.com/foo/bar.git#v1.2.3", None, None)
assert (key, kind, locator) == (
"foo/bar",
True,
"git",
("https://github.com/foo/bar.git", "v1.2.3"),
)
def test_node_key_url_in_name_git_plus_prefix():
key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("git+https://github.com/foo/bar", None, None)
assert (key, kind, locator) == (
"foo/bar",
True,
"git",
("https://github.com/foo/bar", None),
)
def test_node_key_git_plus_prefix_in_repository():
_key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar")
assert (is_git, locator) == (True, ("https://github.com/foo/bar", None))
_key, kind, locator = _node_key("name", None, "git+https://github.com/foo/bar")
assert (kind, locator) == ("git", ("https://github.com/foo/bar", None))
def test_node_key_custom_name_equals_url_is_git():
key, is_git, locator = _node_key(
key, kind, locator = _node_key(
"OneWireNg=https://github.com/pstolarz/OneWireNg", None, None
)
assert (key, is_git, locator) == (
assert (key, kind, locator) == (
"pstolarz/OneWireNg",
True,
"git",
("https://github.com/pstolarz/OneWireNg", None),
)
@@ -506,14 +585,70 @@ def test_node_key_custom_name_equals_url_is_git():
def test_node_key_url_in_name_with_query_containing_equals():
# A bare URL whose query string contains ``=`` must not be split by the
# CustomName=URL handling.
key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None)
assert (key, is_git, locator) == (
key, kind, locator = _node_key("https://host/x/y.git?ref=main", None, None)
assert (key, kind, locator) == (
"x/y",
True,
"git",
("https://host/x/y.git?ref=main", None),
)
def test_node_key_file_url_in_repository_is_local():
# A plain file:// entry (PlatformIO's spelling for a local library folder)
# resolves as a local directory, keeping the custom name as the key. The
# path is the OS-native form of the URL (backslashes on Windows).
key, kind, (path, ref) = _node_key(
"TeslaBLE", None, "file:///config/esphome/lib_dev"
)
assert (key, kind, ref) == ("TeslaBLE", "local", None)
assert Path(path) == Path("/config/esphome/lib_dev")
def test_node_key_bare_file_url_is_local_named_for_dir():
# Without a custom name the directory's own name becomes the key.
key, kind, (path, ref) = _node_key(None, None, "file:///opt/mylib")
assert (key, kind, ref) == ("mylib", "local", None)
assert Path(path) == Path("/opt/mylib")
def test_node_key_custom_name_equals_file_url_is_local():
key, kind, (path, ref) = _node_key("Foo=file:///opt/mylib", None, None)
assert (key, kind, ref) == ("Foo", "local", None)
assert Path(path) == Path("/opt/mylib")
def test_node_key_file_url_localhost_host_is_local():
# A localhost host is ignored; only the path identifies the directory.
key, kind, (path, ref) = _node_key(None, None, "file://localhost/opt/mylib")
assert (key, kind, ref) == ("mylib", "local", None)
assert Path(path) == Path("/opt/mylib")
@pytest.mark.parametrize(
"url", ["file://server/share/lib", "file://lib_dev", "file://../mylib"]
)
def test_node_key_file_url_with_host_rejected(url: str) -> None:
# A real host, or a relative path whose first segment parses as the host,
# is rejected rather than silently resolved to the wrong directory.
with pytest.raises(RuntimeError, match="Unsupported host in file://"):
_node_key(None, None, url)
@pytest.mark.parametrize("url", ["file:lib_dev", "file:./lib", "file:///"])
def test_node_key_file_url_must_be_absolute(url: str) -> None:
# A relative path (no host, e.g. file:lib_dev) or a bare root (file:///)
# is rejected rather than resolved against the cwd or yielding an empty name.
with pytest.raises(RuntimeError, match="must be an absolute"):
_node_key(None, None, url)
def test_node_key_git_plus_file_url_stays_git():
# git+file:// is an explicit local git repo, not a plain directory.
_key, kind, locator = _node_key("X", None, "git+file:///srv/foo.git")
assert kind == "git"
assert locator == ("file:///srv/foo.git", None)
@pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"])
def test_node_key_malformed_url_in_name_raises(name: str) -> None:
# A name that was clearly meant to be a URL but does not parse must fail
@@ -523,25 +658,25 @@ def test_node_key_malformed_url_in_name_raises(name: str) -> None:
def test_node_key_name_with_equals_but_no_url_is_registry():
key, is_git, locator = _node_key("FOO=BAR", "1.0", None)
assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR"))
key, kind, locator = _node_key("FOO=BAR", "1.0", None)
assert (key, kind, locator) == ("FOO=BAR", "registry", (None, "FOO=BAR"))
def test_node_key_version_url_still_ignored_when_name_plain():
# A version that is a URL is handled by the dependency walk, not here;
# a plain name must stay a registry spec regardless of version shape.
key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None)
assert (key, is_git) == ("bar", False)
key, kind, _locator = _node_key("bar", "https://github.com/foo/bar", None)
assert (key, kind) == ("bar", "registry")
def test_node_key_registry_owner_name():
key, is_git, locator = _node_key("foo/bar", "^1.0.0", None)
assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar"))
key, kind, locator = _node_key("foo/bar", "^1.0.0", None)
assert (key, kind, locator) == ("foo/bar", "registry", ("foo", "bar"))
def test_node_key_registry_bare_name():
key, is_git, locator = _node_key("bar", "1.0", None)
assert (key, is_git, locator) == ("bar", False, (None, "bar"))
key, kind, locator = _node_key("bar", "1.0", None)
assert (key, kind, locator) == ("bar", "registry", (None, "bar"))
def test_normalize_dependencies_none():
+12
View File
@@ -178,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
assert not any(c[1] == "fetch" for c in calls)
assert not any(c[1] == "reset" for c in calls)
# The clone must retry transient network failures and clean up a
# partial destination between attempts
clone_kwargs = run_git_command_mock.call_args_list[0].kwargs
assert clone_kwargs["network"] is True
assert clone_kwargs["retry_cleanup"] == framework_path
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
@@ -205,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
]
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
# Clone and fetch talk to the network and must carry the retry flag;
# the local reset must not
kwargs = [c.kwargs for c in run_git_command_mock.call_args_list]
assert kwargs[0]["network"] is True
assert kwargs[0]["retry_cleanup"] == framework_path
assert kwargs[1]["network"] is True
assert "network" not in kwargs[2]
def test_clone_idf_with_submodules_raises_when_tree_missing(
+211
View File
@@ -0,0 +1,211 @@
"""Tests for esphome.espidf.runner."""
from __future__ import annotations
import io
import os
from pathlib import Path
import subprocess
import sys
import threading
import pytest
from esphome.espidf import runner
# A flushing runner delivers the first line in well under a second; this is
# only ever waited out when the shim has gone back to buffering, so keep it
# just long enough to cover interpreter startup on a loaded CI machine.
FIRST_LINE_TIMEOUT = 10.0
def _prepare_main(
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
) -> tuple[io.BytesIO, io.TextIOWrapper]:
"""Point ``runner.main()`` at *probe* with a buffered fake stdout.
``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and
``os.get_terminal_size``; every one of those is monkeypatched so it is
put back afterwards. The fake stdout is block buffered like a pipe, so
the caller can tell whether the shim flushed. The wrapper comes back with
the buffer because dropping it would close the buffer underneath us.
"""
buf = io.BytesIO()
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args])
monkeypatch.setattr(sys, "stdout", stream)
monkeypatch.setattr(sys, "stderr", stream)
monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size)
return buf, stream
def _run_main(
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
) -> tuple[io.BytesIO, io.TextIOWrapper]:
"""Run ``runner.main()`` against *probe* and expect a clean exit."""
buf, stream = _prepare_main(monkeypatch, probe, *args)
assert runner.main() == 0
return buf, stream
def test_main_filters_noise_and_flushes_each_write(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Useful lines reach the stream right away; noisy ones are dropped."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "filtering_probe.py"
)
# Read before any flush of our own: the shim has to have flushed.
output = buf.getvalue().decode("utf-8")
assert "Compiling main.cpp\n" in output
assert "[2/9] Building C object\n" in output
# Matched by FILTER_IDF_LINES, so they never leave the runner.
assert "Project build complete." not in output
assert "-- Component paths:" not in output
# Held back until the end because no terminator arrived.
assert output.endswith("still going\n")
def test_main_keeps_output_after_a_form_feed(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A form feed is text, not a line break, so nothing after it is lost."""
buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py")
assert buf.getvalue().decode("utf-8") == (
"Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n"
)
def test_main_drains_a_partial_line_when_the_build_dies(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A build that stops mid line must still show that line.
This is the whole point of draining: the message explaining why the
build failed is exactly the one most likely to arrive without a
trailing newline.
"""
buf, _stream = _prepare_main(
monkeypatch, fixture_path / "espidf" / "crashing_probe.py"
)
with pytest.raises(SystemExit) as excinfo:
runner.main()
assert excinfo.value.code == 2
assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n"
def test_main_reports_rather_than_raises_when_draining_fails(
monkeypatch: pytest.MonkeyPatch,
fixture_path: Path,
capfd: pytest.CaptureFixture[str],
) -> None:
"""A stream that closed under us must not crash the runner's cleanup.
The drain runs from a ``finally``, so an exception there would replace
whatever exit code the build was carrying back.
"""
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
assert runner.main() == 0
reported = capfd.readouterr().err
assert "Could not write out remaining output" in reported
# The held line has to come along; the stream it was meant for is gone.
assert "partial before close" in reported
def test_main_survives_a_drain_failure_with_nowhere_to_report_it(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""With no real stderr to report to, cleanup still must not raise.
``sys.__stderr__`` is None on some interpreters, and ``print(file=None)``
falls back to ``sys.stdout``, which here is the shim wrapping the stream
that just failed.
"""
monkeypatch.setattr(sys, "__stderr__", None)
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
assert runner.main() == 0
def test_main_still_filters_a_drained_partial_line(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Releasing a held line does not smuggle noise past the filter."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py"
)
assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n"
def test_main_keeps_everything_in_verbose_mode(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""``-v`` turns the filter off so the noisy lines survive."""
buf, _stream = _run_main(
monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v"
)
output = buf.getvalue().decode("utf-8")
assert "Project build complete.\n" in output
assert "-- Component paths: /a /b /c\n" in output
# With no filter there is no line buffering, so the partial line goes
# straight through as well.
assert output.endswith("still going")
def test_runner_streams_output_before_the_build_finishes(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""The runner must flush, or a dashboard build looks frozen.
``toolchain.py`` spawns the runner as a plain script with no ``-u``, and
hands it a pipe when esphome itself is running under the dashboard. A
pipe is block buffered, so without a flush in the shim's ``write()`` the
output sits in the child until 8 KiB piles up or the build ends.
"""
runner_py = Path(runner.__file__)
probe = fixture_path / "espidf" / "streaming_probe.py"
with subprocess.Popen(
[sys.executable, str(runner_py), str(probe)],
stdout=subprocess.PIPE,
# Keep stderr: if the runner dies on startup, its traceback is the
# only clue about why no line showed up.
stderr=subprocess.PIPE,
env=probe_env,
text=True,
) as proc:
assert proc.stdout is not None
assert proc.stderr is not None
first_line: list[str] = []
reader = threading.Thread(
target=lambda: first_line.append(proc.stdout.readline()), daemon=True
)
try:
reader.start()
reader.join(FIRST_LINE_TIMEOUT)
still_running = proc.poll() is None
# The probe sleeps for a minute after writing, so reaching us at
# all means the line was flushed rather than released at exit.
assert first_line == ["Compiling main.cpp\n"], (
f"runner stderr: {'' if still_running else proc.stderr.read()}"
)
assert still_running
finally:
proc.kill()
proc.wait()
# Join before leaving the block, so the reader is done rather than
# racing ``Popen`` closing the pipe under it.
reader.join(1.0)
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
"""Tests for the Happy Eyeballs urllib3 shim."""
from __future__ import annotations
import asyncio
from collections.abc import Generator
import socket
from typing import Any
from unittest.mock import Mock, patch
import pytest
from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs
def _addr_info(host: str, port: int) -> tuple[Any, ...]:
"""Build a getaddrinfo-style result tuple for an IPv4 address."""
return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port))
@pytest.fixture
def create_connection() -> Any:
"""A freshly built Happy Eyeballs create_connection replacement."""
return _make_create_connection()
@pytest.fixture
def listener() -> Generator[tuple[str, int]]:
"""A listening TCP socket on localhost; yields its address."""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(5)
yield server.getsockname()
server.close()
@pytest.fixture
def mock_gai(listener: tuple[str, int]) -> Generator[Any]:
"""Resolve every host to two copies of the listener's address."""
with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock:
yield mock
def test_ensure_happy_eyeballs_patches_and_is_idempotent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The shim replaces urllib3's create_connection exactly once."""
import urllib3.util.connection
def stock(*args: Any, **kwargs: Any) -> None:
pass
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
ensure_happy_eyeballs()
patched = urllib3.util.connection.create_connection
assert patched is not stock
assert patched._esphome_patched
ensure_happy_eyeballs()
assert urllib3.util.connection.create_connection is patched
def test_connects_and_restores_socket_state(
create_connection: Any, listener: tuple[str, int], mock_gai: Any
) -> None:
"""The winning socket comes back blocking, with timeout and options set."""
sock = create_connection(
("example.com", listener[1]),
timeout=5,
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
)
try:
assert sock.getpeername() == listener
assert sock.gettimeout() == 5
assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0
finally:
sock.close()
def test_single_address_connects(
create_connection: Any, listener: tuple[str, int]
) -> None:
"""A host resolving to one address connects through the same path."""
with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]):
sock = create_connection(("example.com", listener[1]), timeout=5)
try:
assert sock.getpeername() == listener
finally:
sock.close()
def test_falls_back_to_working_address(
create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unreachable first address does not block the working one."""
from esphome import happy_eyeballs
# 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the
# network; either way the second address must win well within the
# timeout instead of waiting out the first. A short stagger keeps the
# test's duration network independent.
monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01)
addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)]
with patch("socket.getaddrinfo", return_value=addr_infos):
sock = create_connection(("example.com", listener[1]), timeout=10)
try:
assert sock.getpeername() == listener
finally:
sock.close()
def test_bracketed_ipv6_host_is_stripped(
create_connection: Any, listener: tuple[str, int], mock_gai: Any
) -> None:
"""A bracketed IPv6 literal is unbracketed before resolution."""
sock = create_connection(("[::1]", listener[1]), timeout=5)
try:
assert mock_gai.call_args[0][0] == "::1"
assert sock.getpeername() == listener
finally:
sock.close()
def test_source_address_is_bound(
create_connection: Any, listener: tuple[str, int], mock_gai: Any
) -> None:
"""The socket binds to the requested source address before connecting."""
sock = create_connection(
("example.com", listener[1]),
timeout=5,
source_address=("127.0.0.1", 0),
)
try:
assert sock.getsockname()[0] == "127.0.0.1"
finally:
sock.close()
def test_socket_factory_failure_closes_socket(
listener: tuple[str, int], mock_gai: Any
) -> None:
"""A socket-option failure fails the connect instead of leaking sockets.
Instrumented at ``_set_socket_options`` (which the factory calls with
the just-created socket) rather than by patching ``socket.socket``,
which is platform dependent: the event loop's internal socketpair use
differs between platforms.
"""
created: list[socket.socket] = []
def failing_set_options(sock: socket.socket, options: Any) -> None:
created.append(sock)
raise OSError("bad socket option")
# Patch before building the closure; it binds _set_socket_options at
# creation time.
with patch("urllib3.util.connection._set_socket_options", new=failing_set_options):
create_connection = _make_create_connection()
with pytest.raises(OSError):
create_connection(
("example.com", listener[1]),
timeout=5,
socket_options=[(999999, 999999, 1)],
)
assert created, "socket factory never ran"
assert all(sock.fileno() == -1 for sock in created), "socket leaked open"
def test_default_timeout_yields_blocking_socket(
create_connection: Any, listener: tuple[str, int], mock_gai: Any
) -> None:
"""Without an explicit timeout the socket follows the global default."""
sock = create_connection(("example.com", listener[1]))
try:
assert sock.gettimeout() is socket.getdefaulttimeout()
finally:
sock.close()
def test_settimeout_failure_closes_socket(
create_connection: Any, mock_gai: Any
) -> None:
"""A failure restoring socket state closes the winner instead of leaking."""
bad_sock = Mock()
bad_sock.settimeout.side_effect = OSError("bad timeout")
with (
patch("esphome.async_thread.run_async", return_value=bad_sock),
pytest.raises(OSError, match="bad timeout"),
):
create_connection(("example.com", 80), timeout=5)
bad_sock.close.assert_called_once()
def test_connect_timeout_raises() -> None:
"""A connect that never completes raises within the timeout."""
async def never(*args: Any, **kwargs: Any) -> None:
await asyncio.sleep(60)
addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)]
# Patch before building the closure; it binds start_connection at
# creation time.
with patch("aiohappyeyeballs.start_connection", new=never):
create_connection = _make_create_connection()
with (
patch("socket.getaddrinfo", return_value=addr_infos),
pytest.raises(TimeoutError),
):
create_connection(("example.com", 80), timeout=0.1)
def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None:
"""Hostnames urllib3 would reject are still rejected."""
from urllib3.exceptions import LocationParseError
with pytest.raises(LocationParseError):
create_connection(("a" * 300, 80))
def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None:
"""An empty resolution matches stock urllib3's OSError, not ValueError."""
with (
patch("socket.getaddrinfo", return_value=[]),
pytest.raises(OSError, match="empty"),
):
create_connection(("example.com", 80), timeout=5)
def test_ensure_falls_back_to_stock_when_internals_move(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""If urllib3 private names disappear, downloads keep the stock connect
and the warning is latched to fire once, not per download."""
import urllib3.util.connection
from esphome import happy_eyeballs
def stock(*args: Any, **kwargs: Any) -> None:
pass
factory = Mock(side_effect=ImportError("gone"))
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory)
ensure_happy_eyeballs()
ensure_happy_eyeballs()
assert urllib3.util.connection.create_connection is stock
assert factory.call_count == 1
assert caplog.text.count("Happy Eyeballs unavailable") == 1
def test_ensure_survives_missing_urllib3(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An unimportable urllib3 degrades with a warning instead of raising."""
import sys
with patch.dict(sys.modules, {"urllib3.util.connection": None}):
ensure_happy_eyeballs()
assert "Happy Eyeballs unavailable" in caplog.text
def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patching urllib3's create_connection actually reroutes requests."""
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
import requests
import urllib3.util.connection
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *args: Any) -> None:
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
host, port = server.server_address
calls: list[Any] = []
shim = _make_create_connection()
def counting(*args: Any, **kwargs: Any) -> Any:
calls.append(args)
return shim(*args, **kwargs)
counting._esphome_patched = True
monkeypatch.setattr(urllib3.util.connection, "create_connection", counting)
real_getaddrinfo = socket.getaddrinfo
def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any:
if h == "shim-test.invalid":
return [_addr_info(host, port), _addr_info(host, port)]
return real_getaddrinfo(h, p, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
try:
with requests.Session() as session:
session.trust_env = False
resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5)
assert resp.status_code == 200
assert resp.content == b"ok"
assert calls, "requests did not go through the patched create_connection"
finally:
server.shutdown()
server.server_close()
+18
View File
@@ -1074,3 +1074,21 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
bar = ProgressBar("Uploading", stream=stream)
assert bar.enabled is True
@pytest.mark.parametrize(
("seconds", "expected"),
[
(0, "0s"),
(42, "42s"),
(60, "1min"),
(3661, "1h 1min"),
(86400, "1d"),
(90000, "1d 1h"),
(86700, "1d 5min"),
(-5, "0s"),
],
)
def test_format_duration(seconds: float, expected: str) -> None:
"""Test that durations are rendered as short human-readable strings."""
assert helpers.format_duration(seconds) == expected
+242 -5
View File
@@ -14,6 +14,8 @@ test pins down *which* heavy modules must stay out entirely.
from __future__ import annotations
import importlib.util
from pathlib import Path
import subprocess
import sys
@@ -30,12 +32,50 @@ HEAVY_MODULES = (
"voluptuous",
)
# Everything the storage fast path must keep out of sys.modules; the
# existence guard and the leak check must watch the same list.
FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
# Heavy only for modules that must not know about the API transport;
# in the existence guard so a rename can't silently no-op its check.
API_HEAVY_MODULES = ("aioesphomeapi",)
# Heavy only for the single-config dispatch path: the bundle suffix
# check reads BUNDLE_EXTENSION from esphome.const so an ordinary run
# never pays for the bundle machinery and its tarfile chain.
BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile")
# Heavy only for a cache-hit upload/logs run: the JSON cache parse must
# not resolve pyyaml or the yaml_util chain (the read_config fallback
# still uses both).
CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml")
# Stdlib modules deferred out of the dispatch fast path: a cache-hit
# upload/logs run never writes a file (tempfile), spawns a process
# (subprocess), parses a URL (urllib.parse), or prints a serial
# permission hint (getpass). shutil is deferred too but unwatchable:
# argparse imports it from every add_argument on py3.14. urllib.parse
# is only watchable on 3.13+ where pathlib stopped importing it.
STDLIB_FAST_PATH_MODULES = (
"tempfile",
"subprocess",
"getpass",
"datetime",
*(("urllib.parse",) if sys.version_info >= (3, 13) else ()),
)
def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str:
"""Import ``module`` in a subprocess and report the heavy modules it pulled.
Any ``esphome.components.*`` package counts as heavy: executing a
component package drags in codegen/validation machinery by design.
``extra`` adds modules that are heavy for this caller specifically.
"""
check = (
"import sys; import esphome.__main__; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
f"import sys; import {module}; "
f"leaked = [m for m in {HEAVY_MODULES + extra!r} if m in sys.modules]; "
"leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; "
"print(','.join(leaked))"
)
result = subprocess.run(
@@ -44,10 +84,207 @@ def test_main_module_does_not_import_heavy_modules() -> None:
text=True,
check=True,
)
leaked = result.stdout.strip()
return result.stdout.strip()
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen.
The stdlib watch list rides along here because this check runs in a
clean subprocess: a module-level re-import anywhere on the chain is
caught, which the dispatch fixture (whose setup pre-imports them and
pops before dispatch) structurally cannot do.
"""
leaked = _leaked_heavy_modules("esphome.__main__", extra=STDLIB_FAST_PATH_MODULES)
assert not leaked, (
f"esphome.__main__ imports heavy modules at top level: {leaked}. "
"Import them lazily inside the command that needs them instead; "
"every esphome invocation (including each parallel dashboard "
"upload subprocess) pays for top-level imports."
)
def test_watched_heavy_modules_exist() -> None:
"""A renamed heavy module would silently disable the leak checks."""
for module in (
FAST_PATH_HEAVY_MODULES
+ API_HEAVY_MODULES
+ BUNDLE_HEAVY_MODULES
+ CACHE_HIT_HEAVY_MODULES
+ STDLIB_FAST_PATH_MODULES
):
assert importlib.util.find_spec(module) is not None, (
f"{module} no longer resolves; update the heavy-module lists"
)
def _leaked_from_fixture(
fixture_path: Path,
env: dict[str, str],
script_name: str,
extra: tuple[str, ...] = (),
) -> str:
"""Run a fixture script with the watched modules on argv.
``env`` comes from the ``probe_env`` fixture so the child can import
the repo checkout; a non-zero exit surfaces the child's stderr.
"""
script = fixture_path / "lazy_imports" / script_name
result = subprocess.run(
[sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra],
capture_output=True,
text=True,
env=env,
check=False,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def test_storage_json_fast_path_does_not_import_heavy_modules(
fixture_path: Path,
probe_env: dict[str, str],
) -> None:
"""``apply_to_core`` runs on the upload/logs fast path for every
platform; parsing the stored framework version must not drag in the
validation stack or the esp32 component package.
"""
leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py")
assert not leaked, (
f"storage_json.apply_to_core pulls in heavy modules: {leaked}. "
"The upload/logs fast path skips validation; importing the "
"validation stack anyway defeats the validated-config cache."
)
def test_esptool_upload_fast_path_does_not_import_heavy_modules(
fixture_path: Path,
probe_env: dict[str, str],
) -> None:
"""The esptool serial upload reads the esp32 variant from CORE.data;
resolving it must not drag in the esp32 component package or the
validation stack.
"""
leaked = _leaked_from_fixture(
fixture_path, probe_env, "esptool_upload_fast_path.py"
)
assert not leaked, (
f"upload_using_esptool pulls in heavy modules: {leaked}. "
"The upload fast path skips validation; importing the validation "
"stack anyway defeats the validated-config cache."
)
def test_api_client_does_not_import_heavy_modules() -> None:
"""``esphome.api_client`` is on the logs fast path and must stay light.
Importing it must not execute any component package (the api package
pulls the whole validation stack: logger, esp32, writer, config,
jinja2, voluptuous).
"""
leaked = _leaked_heavy_modules("esphome.api_client")
assert not leaked, (
f"esphome.api_client imports heavy modules at top level: {leaked}. "
"The logs fast path skips validation; importing the validation "
"stack anyway defeats the validated-config cache."
)
def test_stacktrace_does_not_import_heavy_modules() -> None:
"""``esphome.stacktrace`` guards its own docstring's contract.
Both log paths construct a LogLineProcessor before streaming
starts; importing the module must not pull in aioesphomeapi or
any platform package.
"""
leaked = _leaked_heavy_modules("esphome.stacktrace", extra=API_HEAVY_MODULES)
assert not leaked, (
f"esphome.stacktrace imports heavy modules at top level: {leaked}. "
"The logs fast path skips validation; importing the validation "
"stack anyway defeats the validated-config cache."
)
def test_espidf_toolchain_does_not_import_heavy_modules() -> None:
"""The esp-idf upload path must not pull the esp32 package back in.
upload_using_esptool reaches espidf.toolchain for esp-idf builds;
its keys and the variant mapping live in esphome.const and
esphome.espidf precisely so this import stays light.
"""
leaked = _leaked_heavy_modules("esphome.espidf.toolchain")
assert not leaked, (
f"esphome.espidf.toolchain imports heavy modules: {leaked}. "
"The upload fast path skips validation; importing the validation "
"stack anyway defeats the validated-config cache."
)
def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None:
"""``has_mqtt_ip_lookup`` runs on the upload/logs fast path for mqtt
configs; reading ``CONF_DISCOVER_IP`` must not drag in the mqtt
component and, with it, the validation stack.
Runs in a subprocess because this session's other tests import the
mqtt component; the fast path itself must not.
"""
check = (
"import sys; from esphome.__main__ import has_mqtt_ip_lookup; "
"from esphome.core import CORE; from esphome.const import CONF_MQTT; "
"CORE.config = {CONF_MQTT: {}}; "
"assert has_mqtt_ip_lookup() is True, 'mqtt IP lookup default broke'; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
"leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; "
"print(','.join(leaked))"
)
# check=False keeps the child's stderr (its assertion message or an
# import traceback) visible on failure.
result = subprocess.run(
[sys.executable, "-c", check],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
leaked = result.stdout.strip()
assert not leaked, (
f"has_mqtt_ip_lookup pulls in heavy modules: {leaked}. "
"The upload/logs fast path skips validation; importing the "
"validation stack anyway defeats the validated-config cache."
)
def test_yaml_util_does_not_import_heavy_modules() -> None:
"""``esphome.yaml_util`` parses the validated-config cache on the
upload/logs fast path; importing it must not pull in voluptuous.
"""
leaked = _leaked_heavy_modules("esphome.yaml_util")
assert not leaked, (
f"esphome.yaml_util imports heavy modules at top level: {leaked}. "
"The upload/logs fast path skips validation; importing the "
"validation stack anyway defeats the validated-config cache."
)
def test_upload_command_path_does_not_import_heavy_modules(
fixture_path: Path,
probe_env: dict[str, str],
) -> None:
"""The single-config dispatch path checks the bundle suffix on every
run; reading it from esphome.const must not drag in esphome.bundle
and its tarfile chain.
"""
leaked = _leaked_from_fixture(
fixture_path,
probe_env,
"upload_command_fast_path.py",
extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES,
)
assert not leaked, (
f"the upload dispatch path pulls in heavy modules: {leaked}. "
"An ordinary run only needs the bundle suffix constant, and the "
"JSON cache parse must not resolve voluptuous or pyyaml; keep the "
"esphome.bundle import inside the branch that extracts one, the "
"yaml_util imports inside the read_config fallback, and the "
"deferred stdlib imports inside the write/spawn/serial helpers."
)
+266 -1
View File
@@ -1,6 +1,44 @@
from collections.abc import Generator
import errno
import io
import logging
import os
from pathlib import Path
import select
import subprocess
import sys
import time
import pytest
from esphome.log import AnsiFore, AnsiStyle, color
from esphome.core import CORE
from esphome.log import AnsiFore, AnsiStyle, color, setup_log
class _FakeTty(io.StringIO):
def isatty(self) -> bool:
return True
@pytest.fixture
def restore_logging_state() -> Generator[None, None, None]:
"""Undo the global logging changes setup_log() makes."""
root = logging.getLogger()
handlers = root.handlers[:]
formatters = [handler.formatter for handler in handlers]
level = root.level
urllib3_level = logging.getLogger("urllib3").level
yield
root.handlers[:] = handlers
for handler, formatter in zip(handlers, formatters, strict=True):
handler.setFormatter(formatter)
root.setLevel(level)
logging.getLogger("urllib3").setLevel(urllib3_level)
def _probe_command(fixture_path: Path, *args: str) -> list[str]:
"""Build the command line for the setup_log probe fixture script."""
return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args]
def test_color_keep_returns_unchanged_message() -> None:
@@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None:
assert bool(AnsiFore.KEEP) is True
# But the value itself is still an empty string
assert AnsiFore.KEEP.value == ""
@pytest.mark.skipif(
sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_redirected_output_strips_ansi(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""A redirected run must keep colorama so ANSI codes are stripped."""
result = subprocess.run(
_probe_command(fixture_path),
capture_output=True,
text=True,
timeout=60,
check=False,
env=probe_env,
)
assert result.returncode == 0, result.stderr
assert "colorama_loaded=True" in result.stdout
assert "red end" in result.stdout
assert "\033" not in result.stdout
@pytest.mark.skipif(
sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_dashboard_skips_colorama(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""Dashboard runs escape their color codes, so colorama must not load."""
result = subprocess.run(
_probe_command(fixture_path, "--dashboard"),
capture_output=True,
text=True,
timeout=60,
check=False,
env=probe_env,
)
assert result.returncode == 0, result.stderr
assert "colorama_loaded=False" in result.stdout
# Codes pass through untouched for the dashboard to handle.
assert "\033[31mred\033[0m end" in result.stdout
def _run_probe_on_pty(
fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool
) -> str:
"""Run the probe with stdout on a pty and return the decoded pty output.
With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the
mixed tty/redirect stream combination while keeping any traceback
available for the exit assertion.
"""
# Unix-only; a module-level import would break test collection on
# Windows, where all the callers are skipped anyway.
import pty
controller, follower = pty.openpty()
proc = None
output = b""
deadline = time.monotonic() + 60
try:
try:
proc = subprocess.Popen(
_probe_command(fixture_path),
stdout=follower,
stderr=follower if stderr_to_pty else subprocess.PIPE,
stdin=follower,
env=probe_env,
)
finally:
os.close(follower)
while True:
timeout = deadline - time.monotonic()
if timeout <= 0 or not select.select([controller], [], [], timeout)[0]:
pytest.fail(f"pty probe produced no EOF in time; got {output!r}")
try:
chunk = os.read(controller, 1024)
except OSError as err:
# macOS raises EIO once the child closes its end of the pty;
# anything else is a real failure, not end-of-stream.
if err.errno != errno.EIO:
raise
break
if not chunk:
break
output += chunk
stderr_text = ""
if proc.stderr is not None:
stderr_text = proc.stderr.read().decode(errors="replace")
proc.stderr.close()
assert proc.wait(60) == 0, stderr_text
finally:
os.close(controller)
if proc is not None and proc.poll() is None:
proc.kill()
proc.wait()
return output.decode()
@pytest.mark.skipif(
sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
)
def test_setup_log_tty_skips_colorama(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""A terminal run must skip colorama and keep ANSI codes intact."""
text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True)
assert "colorama_loaded=False" in text
assert "\033[31mred\033[0m end" in text
@pytest.mark.skipif(
sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
)
def test_setup_log_mixed_streams_init_colorama(
fixture_path: Path, probe_env: dict[str, str]
) -> None:
"""A tty stdout with a redirected stderr must still initialize colorama.
The guard requires both streams to be a tty; collapsing it to a
single-stream check would stop stripping ANSI from a redirected
stderr while stdout is a terminal.
"""
text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False)
assert "colorama_loaded=True" in text
# stdout is a tty, so colorama leaves its codes alone.
assert "\033[31mred\033[0m end" in text
@pytest.fixture
def colorama_probe(
monkeypatch: pytest.MonkeyPatch, restore_logging_state: None
) -> Generator[None, None, None]:
"""Shared preamble for the in-process guard-branch tests.
Clears colorama from sys.modules so the assertions prove what
setup_log() itself did, and snapshots CORE.verbose/quiet, which is
not a no-op: CORE.reset() does not restore them, so without the
snapshot setup_log()'s log-level side effects would leak into later
tests.
"""
monkeypatch.delitem(sys.modules, "colorama", raising=False)
monkeypatch.setattr(CORE, "verbose", CORE.verbose)
monkeypatch.setattr(CORE, "quiet", CORE.quiet)
yield
# init() rebinds sys.stdout/stderr; restore them before monkeypatch
# puts the originals back.
if (colorama := sys.modules.get("colorama")) is not None:
colorama.deinit()
@pytest.mark.skipif(
sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_dashboard_branch_skips_colorama_import(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
"""The dashboard side of the guard must not import colorama."""
monkeypatch.setattr(CORE, "dashboard", True)
setup_log()
assert "colorama" not in sys.modules
@pytest.mark.skipif(
sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_tty_branch_skips_colorama_import(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
"""The tty side of the guard must not import colorama."""
monkeypatch.setattr(sys, "stdout", _FakeTty())
monkeypatch.setattr(sys, "stderr", _FakeTty())
setup_log()
assert "colorama" not in sys.modules
@pytest.mark.skipif(
sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_redirected_branch_imports_colorama(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
"""Redirected streams must keep importing and initializing colorama."""
monkeypatch.setattr(sys, "stdout", io.StringIO())
monkeypatch.setattr(sys, "stderr", io.StringIO())
setup_log()
assert "colorama" in sys.modules
@pytest.mark.parametrize("broken", ["missing", "closed"])
def test_setup_log_broken_streams_import_colorama(
broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
"""A missing or closed stream counts as a redirect and must not crash.
colorama tolerates both, so setup_log() has to reach its init rather
than raise inside the tty probe.
"""
if broken == "missing":
stream = None
else:
stream = io.StringIO()
stream.close()
monkeypatch.setattr(sys, "stdout", stream)
monkeypatch.setattr(sys, "stderr", stream)
setup_log()
assert "colorama" in sys.modules
def test_setup_log_win32_always_imports_colorama(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
"""The Windows clause must init colorama even when both streams are ttys.
Old Windows consoles need colorama to translate ANSI escapes, so the
platform check has to win over the tty check. colorama itself keys
off os.name, so on a POSIX host its init/deinit pair is a
passthrough.
"""
monkeypatch.setattr(sys, "platform", "win32")
# Both streams are ttys: without the platform clause this combination
# would skip colorama.
monkeypatch.setattr(sys, "stdout", _FakeTty())
monkeypatch.setattr(sys, "stderr", _FakeTty())
setup_log()
assert "colorama" in sys.modules
+327 -33
View File
@@ -18,6 +18,7 @@ import pytest
from pytest import CaptureFixture
from zeroconf import ServiceStateChange
from esphome import __main__ as main
from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
@@ -27,6 +28,7 @@ from esphome.__main__ import (
_unresolved_default_error,
_validate_bootloader_binary,
_validate_partition_table_binary,
check_permissions,
choose_upload_log_host,
command_analyze_memory,
command_bundle,
@@ -63,8 +65,13 @@ from esphome.__main__ import (
)
from esphome.address_cache import AddressCache
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
from esphome.components import esp32
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32
from esphome.components import esp32, esp8266
from esphome.components.esp32 import (
KEY_ESP32,
KEY_VARIANT,
VARIANT_ESP32,
get_esp32_variant,
)
from esphome.const import (
CONF_API,
CONF_AUTH,
@@ -94,6 +101,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
PLATFORM_RP2,
Toolchain,
)
@@ -1621,6 +1629,12 @@ def test_upload_using_esptool_path_conversion(
assert isinstance(partitions_path, str)
assert partitions_path.endswith("partitions.bin")
# The chip argument must track get_esp32_variant: upload_using_esptool
# reads CORE.data directly to avoid the esp32 package import, and the
# two resolutions must not drift.
chip = cmd_list[cmd_list.index("--chip") + 1]
assert chip == get_esp32_variant().lower()
def test_upload_using_esptool_skips_missing_extra_flash_images(
tmp_path: Path,
@@ -2862,18 +2876,17 @@ def test_upload_program_ota_with_mqtt_empty_broker(
assert "MQTT IP discovery failed" in caplog.text
@patch("esphome.__main__.importlib.import_module")
@patch("esphome.platform_hooks.get_platform_hook")
def test_upload_program_platform_specific_handler(
mock_import: Mock,
mock_get_hook: Mock,
mock_get_port_type: Mock,
) -> None:
"""Test upload_program with platform-specific upload handler."""
setup_core(platform="custom_platform")
setup_core(platform=PLATFORM_NRF52)
mock_get_port_type.return_value = "CUSTOM"
mock_module = MagicMock()
mock_module.upload_program.return_value = True
mock_import.return_value = mock_module
platform_upload = MagicMock(return_value=True)
mock_get_hook.return_value = platform_upload
config = {}
args = MockArgs()
@@ -2883,8 +2896,8 @@ def test_upload_program_platform_specific_handler(
assert exit_code == 0
assert host == "custom_device"
mock_import.assert_called_once_with("esphome.components.custom_platform")
mock_module.upload_program.assert_called_once_with(config, args, "custom_device")
mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "upload_program")
platform_upload.assert_called_once_with(config, args, "custom_device")
def test_show_logs_serial(
@@ -2918,7 +2931,7 @@ def test_show_logs_no_logger() -> None:
show_logs(CORE.config, args, devices)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api(
mock_run_logs: Mock,
) -> None:
@@ -2944,7 +2957,7 @@ def test_show_logs_api(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_no_states(
mock_run_logs: Mock,
) -> None:
@@ -2971,7 +2984,7 @@ def test_show_logs_api_no_states(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_with_fqdn_mdns_disabled(
mock_run_logs: Mock,
) -> None:
@@ -2998,7 +3011,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_with_mqtt_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -3108,16 +3121,15 @@ def test_show_logs_no_method_configured() -> None:
show_logs(CORE.config, args, devices)
@patch("esphome.__main__.importlib.import_module")
@patch("esphome.platform_hooks.get_platform_hook")
def test_show_logs_platform_specific_handler(
mock_import: Mock,
mock_get_hook: Mock,
) -> None:
"""Test show_logs with platform-specific logs handler."""
setup_core(platform="custom_platform", config={"logger": {}})
setup_core(platform=PLATFORM_NRF52, config={"logger": {}})
mock_module = MagicMock()
mock_module.show_logs.return_value = True
mock_import.return_value = mock_module
platform_show_logs = MagicMock(return_value=True)
mock_get_hook.return_value = platform_show_logs
config = {"logger": {}}
args = MockArgs()
@@ -3126,8 +3138,8 @@ def test_show_logs_platform_specific_handler(
result = show_logs(config, args, devices)
assert result == 0
mock_import.assert_called_once_with("esphome.components.custom_platform")
mock_module.show_logs.assert_called_once_with(config, args, devices)
mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "show_logs")
platform_show_logs.assert_called_once_with(config, args, devices)
def test_has_mqtt_logging_no_log_topic() -> None:
@@ -3247,6 +3259,14 @@ def test_get_port_type() -> None:
assert get_port_type("BOOTSEL") == "BOOTSEL"
def test_mqtt_reexports_discover_ip() -> None:
"""The old import path must keep working for external code."""
from esphome.components import mqtt
from esphome.const import CONF_DISCOVER_IP
assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP
def test_has_mqtt_ip_lookup() -> None:
"""Test has_mqtt_ip_lookup function."""
@@ -4974,7 +4994,7 @@ def test_upload_program_ota_mqttip_deduplication(
assert "192.168.1.100" in call_args[0]
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_static_ip_with_mqttip(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -5013,7 +5033,7 @@ def test_show_logs_api_static_ip_with_mqttip(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_multiple_mqttip_resolves_once(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -5096,7 +5116,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_mqtt_timeout_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -5717,6 +5737,65 @@ def test_run_miniterm_batches_lines_with_same_timestamp(
)
def test_run_miniterm_analyzer_import_failure_keeps_streaming(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A broken platform import must not stop serial log streaming.
The decoder resolves lazily, so a crash-shaped line has to arrive
before the import is attempted at all.
"""
mock_serial = MockSerial([b"PC: 0x40104960\r\n", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
"deassert_rts_dtr": False,
}
}
args = MockArgs()
with (
caplog.at_level("INFO", logger="esphome.platform_hooks"),
patch("serial.Serial", return_value=mock_serial),
patch(
"esphome.platform_hooks.get_platform_hook",
side_effect=ImportError("broken platform package"),
),
):
result = run_miniterm(config, "/dev/ttyUSB0", args)
assert result == 0
# A broken package is distinguishable from a plain capability gap.
assert "failed to import: broken platform package" in caplog.text
def test_run_miniterm_no_stacktrace_analyzer(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Platforms without a stacktrace analyzer log an info and stream anyway."""
mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_BK72XX}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
"deassert_rts_dtr": False,
}
}
args = MockArgs()
with (
caplog.at_level("INFO", logger="esphome.platform_hooks"),
patch("serial.Serial", return_value=mock_serial),
):
result = run_miniterm(config, "/dev/ttyUSB0", args)
assert result == 0
assert "Stacktrace analysis is unavailable" in caplog.text
def test_run_miniterm_different_chunks_different_timestamps(
capfd: CaptureFixture[str],
) -> None:
@@ -5797,7 +5876,9 @@ def test_run_miniterm_backtrace_state_maintained() -> None:
mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
# An esp8266 dump on an esp8266 session; the platform-scoped gate
# would rightly never resolve esp32's decoder for these lines.
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -5823,7 +5904,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None:
with (
patch("serial.Serial", return_value=mock_serial),
patch.object(
esp32,
esp8266,
"process_stacktrace",
side_effect=track_backtrace_state,
),
@@ -5850,6 +5931,38 @@ def test_run_miniterm_backtrace_state_maintained() -> None:
assert backtrace_states[3][1] is True
def test_run_miniterm_decoder_failure_keeps_streaming(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A decoder exception must not kill serial streaming.
This is the serial path's gain from sharing LogLineProcessor: before
the lift a decoder exception propagated out of the read loop.
"""
chunk = b"PC: 0x4010496e\r\nBT0: 0x4010496e\r\nstill streaming\r\n"
mock_serial = MockSerial([chunk, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
"deassert_rts_dtr": False,
}
}
args = MockArgs()
decoder = Mock(side_effect=EsphomeError("no idedata"))
with (
patch("serial.Serial", return_value=mock_serial),
patch.object(esp32, "process_stacktrace", decoder),
):
run_miniterm(config, "/dev/ttyUSB0", args)
# The failure is contained and latched; streaming continued to EOF.
assert decoder.call_count == 1
assert "Crash trace decoding unavailable" in caplog.text
def test_run_miniterm_handles_empty_reads(
capfd: CaptureFixture[str],
) -> None:
@@ -6196,7 +6309,6 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None:
extracted_yaml = tmp_path / "extracted" / "device.yaml"
with (
patch("esphome.bundle.is_bundle_path", return_value=True) as mock_is_bundle,
patch(
"esphome.bundle.prepare_bundle_for_compile",
return_value=extracted_yaml,
@@ -6205,7 +6317,6 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None:
):
result = run_esphome(["esphome", "compile", str(bundle_path)])
mock_is_bundle.assert_called_once()
mock_prepare.assert_called_once_with(bundle_path)
# read_config returns None → exit code 2
assert result == 2
@@ -6217,13 +6328,11 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None:
yaml_file.write_text("esphome:\n name: test\n")
with (
patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle,
patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare,
patch("esphome.config.read_config", return_value=None),
):
result = run_esphome(["esphome", "compile", str(yaml_file)])
mock_is_bundle.assert_called_once()
mock_prepare.assert_not_called()
assert result == 2
@@ -6254,6 +6363,26 @@ def test_run_esphome_skip_external_update_per_command(
assert mock_read.call_args.kwargs["skip_external_update"] is expected_skip
@pytest.mark.parametrize(
("argv_extra", "expected"),
[(["--no-defaults"], True), ([], False)],
)
def test_run_esphome_snapshot_user_config_only_for_no_defaults(
tmp_path: Path, argv_extra: list[str], expected: bool
) -> None:
"""read_config is invoked with snapshot_user_config=True only when the
config command is run with --no-defaults; otherwise the expensive deep
copy is skipped."""
yaml_file = tmp_path / "device.yaml"
yaml_file.write_text("esphome:\n name: test\n")
with patch("esphome.config.read_config", return_value=None) as mock_read:
run_esphome(["esphome", "config", str(yaml_file), *argv_extra])
mock_read.assert_called_once()
assert mock_read.call_args.kwargs["snapshot_user_config"] is expected
def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None:
"""Test reading XTAL_FREQ from sdkconfig."""
CORE.name = "test-device"
@@ -6468,7 +6597,7 @@ def test_should_subscribe_states_no_flag_overrides_env() -> None:
assert _should_subscribe_states(args) is False
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_command_run_passes_no_states_to_show_logs(
mock_run_logs: Mock,
) -> None:
@@ -6506,7 +6635,7 @@ def test_command_run_passes_no_states_to_show_logs(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_command_run_defaults_subscribe_states_true(
mock_run_logs: Mock,
) -> None:
@@ -6608,3 +6737,168 @@ def test_command_idedata_esp_idf_no_build_errors() -> None:
result = command_idedata(MagicMock(), CORE.config)
assert result == 1
@pytest.mark.skipif(
os.name != "posix", reason="serial permission checks are posix-only"
)
def test_check_permissions_missing_port() -> None:
"""A nonexistent serial port raises the does-not-exist guidance."""
with (
patch("os.access", return_value=False),
pytest.raises(EsphomeError, match="serial port does not exist"),
):
check_permissions("/dev/ttyUSB99")
@pytest.mark.skipif(
os.name != "posix", reason="serial permission checks are posix-only"
)
def test_check_permissions_unreadable_port() -> None:
"""An existing but unreadable serial port raises the dialout guidance."""
with (
patch("os.access", side_effect=lambda _path, mode: mode == os.F_OK),
pytest.raises(EsphomeError, match="read or write permission"),
):
check_permissions("/dev/ttyUSB99")
def _make_checkout(root: Path) -> Path:
"""Create a directory that looks like an esphome checkout."""
(root / "esphome").mkdir(parents=True)
(root / "esphome" / "__main__.py").write_text("", encoding="utf-8")
return root
def test_warn_source_tree_mismatch_warns_for_other_tree(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Standing in a checkout other than the one being run warns."""
standing_in = _make_checkout(tmp_path / "worktree")
running = _make_checkout(tmp_path / "main")
monkeypatch.chdir(standing_in)
monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py"))
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert "worktree" in caplog.text
assert "main" in caplog.text
def test_warn_source_tree_mismatch_silent_in_same_tree(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Standing in the tree that is running is the normal case and is silent."""
tree = _make_checkout(tmp_path / "main")
monkeypatch.chdir(tree)
monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py"))
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert not caplog.text
def test_warn_source_tree_mismatch_silent_outside_checkout(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An ordinary install run from a config directory never warns."""
running = _make_checkout(tmp_path / "main")
config_dir = tmp_path / "configs"
config_dir.mkdir()
monkeypatch.chdir(config_dir)
monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py"))
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert not caplog.text
def test_warn_source_tree_mismatch_silent_in_subdirectory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A subdirectory of the running tree resolves to that tree, so no warning."""
tree = _make_checkout(tmp_path / "main")
subdir = tree / "esphome" / "components"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)
monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py"))
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert not caplog.text
def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The samefile() fallback must still warn when the trees really differ."""
standing_in = _make_checkout(tmp_path / "worktree")
running = _make_checkout(tmp_path / "main")
monkeypatch.chdir(standing_in)
monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py"))
def raise_oserror(self: Path, other: Path) -> bool:
raise OSError("stat failed")
monkeypatch.setattr(Path, "samefile", raise_oserror)
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert "worktree" in caplog.text
def test_warn_source_tree_mismatch_silent_when_cwd_is_gone(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A deleted working directory must not turn the diagnostic into a traceback."""
running = _make_checkout(tmp_path / "main")
monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py"))
def raise_filenotfound() -> Path:
raise FileNotFoundError("cwd is gone")
monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound))
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
assert not caplog.text
def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""If samefile() cannot stat, fall back to comparing the paths."""
tree = _make_checkout(tmp_path / "main")
monkeypatch.chdir(tree)
monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py"))
def raise_oserror(self: Path, other: Path) -> bool:
raise OSError("stat failed")
monkeypatch.setattr(Path, "samefile", raise_oserror)
with caplog.at_level(logging.WARNING):
main._warn_if_source_tree_mismatch()
# Same tree, so the path comparison still finds them equal and stays silent
assert not caplog.text
+2 -2
View File
@@ -13,9 +13,9 @@ from esphome.components.zephyr.const import (
KEY_EXTRA_BUILD_FILES,
KEY_KCONFIG,
KEY_OVERLAY,
KEY_OVERLAY_BUILDER,
KEY_PM_STATIC,
KEY_PRJ_CONF,
KEY_USER,
KEY_ZEPHYR,
)
import esphome.config_validation as cv
@@ -53,9 +53,9 @@ def _setup_nrf52_core(
KEY_BOOTLOADER: bootloader,
KEY_PRJ_CONF: {},
KEY_OVERLAY: {"": ""},
KEY_OVERLAY_BUILDER: [],
KEY_EXTRA_BUILD_FILES: {},
KEY_PM_STATIC: [],
KEY_USER: {},
KEY_KCONFIG: "",
}
+186
View File
@@ -0,0 +1,186 @@
"""Guard the platform CLI-hook registry in ``esphome.platform_hooks``.
The registry lets the logs/upload fast path skip importing platform
packages that don't provide a hook; these tests fail when a platform
gains or loses a hook without the registry being updated, and pin down
that the fast path really avoids the import.
"""
from __future__ import annotations
import importlib
import logging
from unittest.mock import Mock
import pytest
from esphome import platform_hooks
from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform
def test_no_unregistered_platform_exposes_a_hook() -> None:
"""Every platform hook the packages expose must be registered.
Behavioural on purpose: a hook added as a re-export, an assignment,
or an ``async def`` is invisible to source scanning but very visible
to ``hasattr``, and an unregistered hook is silently never called.
The registered direction is covered by
test_every_registered_pair_resolves below.
"""
for platform in frozenset(Platform):
module = importlib.import_module(f"esphome.components.{platform}")
for hook, registered in platform_hooks.PLATFORM_HOOKS.items():
if hasattr(module, hook):
assert platform in registered, (
f"{platform} exposes {hook} but is not registered for it. "
"Update esphome/platform_hooks.py."
)
def test_registered_platform_resolves_hook() -> None:
hook = platform_hooks.get_platform_hook(PLATFORM_ESP32, "process_stacktrace")
from esphome.components import esp32
assert hook is esp32.process_stacktrace
def test_every_registered_pair_resolves() -> None:
"""Each registered platform must actually expose the hook at runtime.
Text scanning can miss re-exports or decorated definitions; this is
the behavioural check for the direction that matters when the CLI
runs.
"""
for hook, platforms in platform_hooks.PLATFORM_HOOKS.items():
for platform in platforms:
assert callable(platform_hooks.get_platform_hook(platform, hook)), (
f"{platform} is registered for {hook} but does not expose it"
)
def test_external_platform_falls_back_to_probe(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Out-of-tree target platforms keep working via the dynamic probe."""
module = type("FakePlatform", (), {"show_logs": staticmethod(lambda *a: True)})
imported: list[str] = []
def fake_import(name: str):
imported.append(name)
return module
monkeypatch.setattr(platform_hooks, "import_module", fake_import)
hook = platform_hooks.get_platform_hook("my_external_chip", "show_logs")
assert hook is module.show_logs
assert imported == ["esphome.components.my_external_chip"]
def test_external_platform_missing_module_degrades(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A warm-cache run may not have the external package importable.
Skipping a behavior-changing hook is visible at warning; losing
stacktrace decoding is cosmetic and stays at debug.
"""
monkeypatch.setattr(
platform_hooks,
"import_module",
Mock(
side_effect=ModuleNotFoundError(
"not found", name="esphome.components.my_external_chip"
)
),
)
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
assert "not importable" in caplog.text
assert any(r.levelname == "WARNING" for r in caplog.records)
caplog.clear()
assert (
platform_hooks.get_platform_hook("my_external_chip", "process_stacktrace")
is None
)
assert not any(r.levelname == "WARNING" for r in caplog.records)
def test_external_platform_without_hook_logs_debug(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The common no-hook case stays quiet but diagnosable."""
caplog.set_level("DEBUG", logger="esphome.platform_hooks")
module = type("ExternalPlatform", (), {}) # imports fine, no hook
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
assert "does not expose" in caplog.text
assert not any(r.levelname == "WARNING" for r in caplog.records)
def test_stale_registry_entry_warns(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A vendored tree where a registered hook vanished must say so."""
module = type("StalePlatform", (), {}) # registered but no hook
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
assert platform_hooks.get_platform_hook("nrf52", "show_logs") is None
assert "no longer exposes it" in caplog.text
def test_external_platform_broken_dependency_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A missing dependency inside the external package must surface."""
monkeypatch.setattr(
platform_hooks,
"import_module",
Mock(side_effect=ModuleNotFoundError("not found", name="some_missing_dep")),
)
with pytest.raises(ModuleNotFoundError, match="not found"):
platform_hooks.get_platform_hook("my_external_chip", "show_logs")
def test_lookup_miss_does_not_import_platform_package(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The whole point: probing a platform without hooks must not import it."""
monkeypatch.setattr(
platform_hooks,
"import_module",
Mock(side_effect=AssertionError("platform package imported on registry miss")),
)
assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None
def test_get_stacktrace_handler_resolves_registered_platform() -> None:
hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32)
from esphome.components import esp32
assert hook is esp32.process_stacktrace
def test_get_stacktrace_handler_reports_missing_analyzer(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO", logger="esphome.platform_hooks")
assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None
assert "no compatible analyzer" in caplog.text
# A capability gap is ordinary; it must not warn.
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
def test_get_stacktrace_handler_reports_import_failure(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setattr(
platform_hooks,
"import_module",
Mock(side_effect=ImportError("broken install")),
)
assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None
assert "failed to import: broken install" in caplog.text
# A broken install is a real breakage; it must warn, not inform.
assert any(r.levelno == logging.WARNING for r in caplog.records)
+198 -1
View File
@@ -10,13 +10,14 @@ from pathlib import Path
import pytest
from esphome.core import Library
from esphome.core import EsphomeError, Library
import esphome.platformio.library as lib
from esphome.platformio.library import (
ConvertedLibrary,
GitSource,
InvalidLibrary,
LibraryBackend,
LocalSource,
Source,
URLSource,
_resolve_registry_version,
@@ -87,6 +88,68 @@ def test_gitsource_str_includes_ref_when_present():
assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git"
def test_source_root_defaults_to_build_dir() -> None:
# Registry/git sources are read from where they were downloaded.
build = Path("/some/build/dir")
assert URLSource("http://x/y.tar.gz").source_root(build) == build
assert GitSource("http://x/y.git", None).source_root(build) == build
def test_converted_library_source_dir_defaults_to_path() -> None:
c = ConvertedLibrary("x", "1.0", source=None)
c.path = Path("/build")
assert c.source_dir == Path("/build") # no source_path set -> build dir
c.source_path = Path("/user/lib")
assert c.source_dir == Path("/user/lib")
def test_convert_libraries_local_missing_manifest_is_esphome_error(
setup_core: Path,
) -> None:
# A local directory that has no library.json/library.properties is user
# input, so it must surface as a clean EsphomeError (named at the user's dir).
src = setup_core / "not_a_lib"
src.mkdir() # exists, but no manifest
# match= is a regex; a Windows path has backslashes, so match a literal
# fragment and check the directory is named separately.
with pytest.raises(EsphomeError, match="missing library.json") as excinfo:
convert_libraries([Library("Foo", None, src.as_uri())], _backend())
assert str(src) in str(excinfo.value)
def test_localsource_download_missing_dir_raises(tmp_path: Path) -> None:
# EsphomeError so the CLI prints it cleanly instead of a traceback.
with pytest.raises(EsphomeError, match="does not exist"):
LocalSource(str(tmp_path / "nope")).download("mylib")
def test_localsource_str() -> None:
assert str(LocalSource("/tmp/lib")) == "file:///tmp/lib"
# A relative path can't form a file:// URI; fall back rather than raise.
assert str(LocalSource("rel/lib")) == "file://rel/lib"
def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None:
# Nothing is copied: download() returns an empty build dir (for generated
# files), and source_root() points back at the user's directory.
src = setup_core / "lib_dev"
(src / "src").mkdir(parents=True)
(src / "library.json").write_text("{}")
(src / "src" / "a.cpp").write_text("int a;")
source = LocalSource(str(src))
out = source.download("mylib", salt="s", namespace="ns")
assert out.is_dir()
assert list(out.iterdir()) == [] # no sources copied in
assert out != src
assert source.source_root(out) == src
# salt/namespace change the cache path.
plain = LocalSource(str(src)).download("mylib")
assert plain != out
def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch):
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
dl_calls: list[list[str]] = []
@@ -317,6 +380,140 @@ def test_convert_libraries_url_in_name_resolves_as_git(
assert source.ref is None
def test_convert_libraries_file_url_resolves_as_local(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A "Name=file://<dir>" library points at an on-disk folder: it resolves as a
# local source read in place (no copy), and the registry is never consulted.
src = setup_core / "lib_dev"
(src / "src").mkdir(parents=True)
(src / "library.json").write_text(json.dumps({"name": "TeslaBLE"}))
(src / "src" / "tesla.cpp").write_text("int foo() { return 1; }")
def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None:
raise AssertionError(f"registry consulted for {owner}/{pkgname}")
monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry)
# as_uri() produces a valid file:// URL on every platform (file:///tmp/... on
# POSIX, file:///C:/... on Windows).
top = convert_libraries([Library("TeslaBLE", None, src.as_uri())], _backend())
assert [c.name for c in top] == ["TeslaBLE"]
assert top[0].data["name"] == "TeslaBLE"
assert isinstance(top[0].source, LocalSource)
# Sources are read in place from the user's dir; the build dir stays separate
# and holds no copied sources.
assert top[0].source_path == src
assert top[0].path != src
assert not (top[0].path / "src").exists()
def test_convert_libraries_local_overrides_registry_version(
setup_core: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# The same library requested both from the registry (with a version) and as
# a local directory resolves to the local source, with a warning that the
# registry version was dropped.
src = setup_core / "lib_dev"
(src / "src").mkdir(parents=True)
(src / "library.json").write_text(json.dumps({"name": "TeslaBLE"}))
def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None:
raise AssertionError(f"registry consulted for {owner}/{pkgname}")
monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry)
with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"):
top = convert_libraries(
[
Library("TeslaBLE", "1.0.0", None),
Library("TeslaBLE", None, src.as_uri()),
],
_backend(),
)
assert isinstance(top[0].source, LocalSource)
assert "local source" in caplog.text
def test_convert_libraries_versionless_registry_and_local_warns(
setup_core: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# A bare cg.add_library("Foo") (versionless registry, the common case) that
# collides with a local directory of the same key must still warn -- the
# registry spec is dropped and the local folder silently takes over.
src = setup_core / "foo"
src.mkdir()
(src / "library.json").write_text(json.dumps({"name": "Foo"}))
def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None:
raise AssertionError(f"registry consulted for {owner}/{pkgname}")
monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry)
with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"):
top = convert_libraries(
[Library("Foo", None, None), Library("Foo", None, src.as_uri())],
_backend(),
)
assert isinstance(top[0].source, LocalSource)
assert "a registry package" in caplog.text
def test_convert_libraries_two_local_dirs_warns(
setup_core: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# The same key pointed at two local directories warns and uses the last one.
dir_a = setup_core / "a"
dir_b = setup_core / "b"
for d in (dir_a, dir_b):
d.mkdir()
(d / "library.json").write_text(json.dumps({"name": "Foo"}))
with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"):
top = convert_libraries(
[
Library("Foo", None, dir_a.as_uri()),
Library("Foo", None, dir_b.as_uri()),
],
_backend(),
)
assert isinstance(top[0].source, LocalSource)
assert top[0].source_path == dir_b # the last one wins
assert "two local directories" in caplog.text
@pytest.mark.parametrize("local_first", [True, False])
def test_convert_libraries_git_and_local_same_key_warns(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
local_first: bool,
) -> None:
# A key requested as both a git source and a local directory warns and uses
# git, whichever order they appear in. The git URL basename matches the local
# custom name so both map to the key "Foo".
_patch_download_with_manifests(monkeypatch, tmp_path, {"Foo": {"name": "Foo"}})
git = Library("X", None, "https://host/Foo")
local = Library("Foo", None, "file:///abs/foo")
libs = [local, git] if local_first else [git, local]
with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"):
top = convert_libraries(libs, _backend())
assert isinstance(top[0].source, GitSource)
assert "using the git source" in caplog.text
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).
@@ -0,0 +1,93 @@
"""Tests for esphome.platformio.runner."""
from __future__ import annotations
from collections.abc import Callable
import io
import sys
from types import ModuleType
import pytest
from esphome.platformio import runner
def _prepare_main(
monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int]
) -> io.BytesIO:
"""Point ``runner.main()`` at a fake PlatformIO with a fake stdout.
The real ``main`` patches PlatformIO internals and then hands control to
it; both are stubbed out so only the stream wrapping is exercised. The
fake stdout is block buffered like a pipe, so the caller can see what
actually left the wrapper.
"""
buf = io.BytesIO()
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
monkeypatch.setattr(sys, "argv", ["pio", "run"])
monkeypatch.setattr(sys, "stdout", stream)
monkeypatch.setattr(sys, "stderr", stream)
monkeypatch.setattr(runner, "patch_structhash", lambda: None)
monkeypatch.setattr(runner, "patch_file_downloader", lambda: None)
platformio = ModuleType("platformio")
platformio_main = ModuleType("platformio.__main__")
platformio_main.main = pio_main # type: ignore[attr-defined]
platformio.__main__ = platformio_main # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "platformio", platformio)
monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main)
return buf
def test_main_drains_a_partial_line_on_a_clean_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A build ending mid line still shows that line."""
def pio_main() -> int:
print("Linking .pioenvs/firmware.elf\n", end="")
print("Building took 12.4 seconds", end="")
return 0
buf = _prepare_main(monkeypatch, pio_main)
assert runner.main() == 0
assert buf.getvalue().decode("utf-8") == (
"Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n"
)
def test_main_drains_when_platformio_exits_early(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Leaving through ``sys.exit`` still drains, because it runs in a finally."""
def pio_main() -> int:
print("*** [.pioenvs/firmware.elf] Error 1", end="")
sys.exit(1)
buf = _prepare_main(monkeypatch, pio_main)
with pytest.raises(SystemExit) as excinfo:
runner.main()
assert excinfo.value.code == 1
assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n"
def test_main_still_filters_a_drained_partial_line(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Releasing a held line does not smuggle noise past the filter."""
def pio_main() -> int:
# Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator.
print("Verbose mode can be enabled via `-v, --verbose` option", end="")
return 0
buf = _prepare_main(monkeypatch, pio_main)
assert runner.main() == 0
assert buf.getvalue() == b""
+106 -4
View File
@@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch
import pytest
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
from esphome.platformio import runner, toolchain
from esphome.util import FlashImage
from esphome.util import ESP32_ARDUINO_ENV, FlashImage
def test_idedata_firmware_elf_path(setup_core: Path) -> None:
@@ -278,15 +279,116 @@ def test_run_idedata_raises_on_no_json(
def test_run_idedata_raises_on_invalid_json(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""Test _run_idedata raises on malformed JSON."""
"""Malformed JSON is the environment (garbage stdout), so it must
surface as EsphomeError and get the recompile hint downstream.
"""
config = {"name": "test"}
mock_run_platformio_cli_run.return_value = '{"invalid": json"}'
# The ValueError from json.loads is re-raised
with pytest.raises(ValueError):
with pytest.raises(EsphomeError):
toolchain._run_idedata(config)
def test_run_idedata_raises_on_launch_failure(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""A failed platformio launch returns its exit code as an int; that
must surface as EsphomeError, not a TypeError from re.search.
"""
config = {"name": "test"}
mock_run_platformio_cli_run.return_value = 1
with pytest.raises(EsphomeError):
toolchain._run_idedata(config)
def test_idedata_missing_prog_path_raises_esphome_error(setup_core: Path) -> None:
"""A stale cached idedata JSON without prog_path is the build tree's
fault; it must surface as EsphomeError, not a KeyError.
"""
with pytest.raises(EsphomeError):
_ = toolchain.IDEData({}).firmware_elf_path
def test_idedata_missing_flash_image_field_raises_esphome_error(
setup_core: Path,
) -> None:
"""A cached idedata whose flash image entries lost a field must
classify as an environment error too, not a raw KeyError.
"""
idedata = toolchain.IDEData({"extra": {"flash_images": [{"offset": "0x1000"}]}})
with pytest.raises(EsphomeError):
_ = idedata.extra_flash_images
def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None:
"""A section that is null instead of absent must classify the same
as a missing key instead of escaping as TypeError.
"""
with pytest.raises(EsphomeError):
_ = toolchain.IDEData({"extra": None}).extra_flash_images
@pytest.mark.parametrize(
("platform", "framework", "expected"),
[
("esp32", "arduino", "1"),
("esp32", "esp-idf", None),
("esp8266", "arduino", None),
],
)
def test_run_platformio_cli_flags_an_esp32_arduino_build(
setup_core: Path,
mock_run_external_process: Mock,
platform: str,
framework: str,
expected: str | None,
) -> None:
"""Only an ESP32 Arduino build is flagged, and an inherited one is cleared."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: platform,
KEY_TARGET_FRAMEWORK: framework,
}
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert env.get(ESP32_ARDUINO_ENV) == expected
# Only the subprocess env is touched; ours is left as it was.
assert os.environ[ESP32_ARDUINO_ENV] == "1"
def test_run_platformio_cli_ignores_an_inherited_flag_without_core(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""An inherited flag must not end up answering for CORE."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data.pop(KEY_CORE, None)
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert ESP32_ARDUINO_ENV not in env
def test_run_platformio_cli_raises_on_a_half_filled_core(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""A CORE set up but left incomplete must surface, not fall back."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data[KEY_CORE] = {}
with patch.dict(os.environ, {}, clear=False):
mock_run_external_process.return_value = 0
with pytest.raises(KeyError):
toolchain.run_platformio_cli("test", "arg")
def test_run_platformio_cli_sets_environment_variables(
setup_core: Path, mock_run_external_process: Mock
) -> None:
@@ -0,0 +1,239 @@
"""Tests to verify preference and entity key hash values remain stable.
These tests ensure the hash algorithms do NOT change, as any change would cause
users to lose stored preferences (calibration values, restore states, etc.) on
firmware upgrades, or break entity state routing to API clients.
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1).
Existing devices have preferences stored under keys derived from it; slot-based
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
Sent to API clients and used as the preference key base on key-lookup backends.
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
the change breaks backward compatibility and will cause data loss.
"""
import pytest
from esphome.helpers import (
FNV1_OFFSET_BASIS,
FNV1_PRIME,
fnv1_hash_name,
fnv1_hash_object_id,
)
# =============================================================================
# Test: fnv1_hash_object_id produces stable hashes for entity names
# =============================================================================
@pytest.mark.parametrize(
("entity_name", "expected_object_id_hash"),
[
# =====================================================================
# Core entity types - these names appear in many ESPHome configurations
# =====================================================================
# Basic single-word names
("Light", 0x735CF023),
("Switch", 0xBEDF78E5),
("Sensor", 0x75E61B1B),
("Fan", 0x468F6780),
("Climate", 0xAA22FD4A),
("Cover", 0xA630D0A2),
("Lock", 0x1D2FD708),
("Valve", 0x25ED5F65),
("Button", 0x3A42C455),
("Number", 0xB900E22A),
("Select", 0x556391B5),
("Text", 0xB12BFA38),
# Multi-word names (spaces become underscores, lowercase)
("Living Room Light", 0xC6F81EC9),
("Kitchen Switch", 0xC63C0F6E),
("Temperature Sensor", 0x16AF55B6),
("Garage Door Cover", 0x685E5281),
("Bedroom Fan", 0x21AB1DED),
("Front Door Lock", 0xB9BEF8E1),
# Already snake_case names (should hash same as space-separated)
("living_room_light", 0xC6F81EC9), # Same as "Living Room Light"
("kitchen_switch", 0xC63C0F6E), # Same as "Kitchen Switch"
# Names with numbers
("Sensor 1", 0x99828E4B),
("Relay 2", 0x6FFEF2FB),
("Zone 10", 0xFD83AA95),
# Names with special characters (become underscores)
("AC Unit", 0x336C6886),
("WiFi Signal", 0x2FA52175),
("CO2 Level", 0x31049870),
# Mixed case handling
("mySwitch", 0x9AA10553),
("MySwitch", 0x9AA10553), # Same as lowercase
("MYSWITCH", 0x9AA10553), # Same as lowercase
# =====================================================================
# Edge cases
# =====================================================================
# Empty name (hashes to the FNV-1 offset basis since no chars processed)
("", 0x811C9DC5),
# Single character
("a", 0x050C5D7E),
("A", 0x050C5D7E), # Same after lowercase
("1", 0x050C5D2E),
("_", 0x050C5D40),
# Names that differ only in case (should hash identically)
("test", 0xBC2C0BE9),
("Test", 0xBC2C0BE9),
("TEST", 0xBC2C0BE9),
# Names that differ only in spaces vs underscores (should hash identically)
("foo bar", 0x3AE35AA1),
("foo_bar", 0x3AE35AA1),
("Foo Bar", 0x3AE35AA1),
("FOO_BAR", 0x3AE35AA1),
# Non-ASCII names (sanitized per code point, one underscore per character)
("äöü", 0x10028B12),
("温度", 0x3276CB9F),
("Température", 0x965698F3),
# =====================================================================
# Real-world component entity names from ESPHome codebase
# =====================================================================
# From fan.cpp - FanRestoreState
("Ceiling Fan", 0x640DEF00),
# From climate.cpp - ClimateRestoreState
("HVAC", 0xDD68438B),
("Thermostat", 0x30A5B7C6),
# From light/light_state.cpp
("LED Strip", 0x2A068423),
("Dimmable Light", 0xD70393F3),
# From cover/cover.cpp
("Garage Door", 0x53987A5D),
("Window Blind", 0x851291A5),
# From switch/switch.cpp
("Relay", 0xD3A92FE4),
("Power Switch", 0x5C4A47B3),
# From number/automation.cpp
("Brightness", 0xF46E252C),
("Volume", 0x8FFEBE43),
# From template datetime entities
("Wake Time", 0xEE612B53),
("Schedule Date", 0xF538C8DD),
],
)
def test_entity_object_id_hash_stability(
entity_name: str, expected_object_id_hash: int
) -> None:
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
preferences stored under keys derived from this legacy hash; changing it
breaks the old-to-new key migration and loses stored preferences.
"""
actual = fnv1_hash_object_id(entity_name)
assert actual == expected_object_id_hash, (
f"Hash for '{entity_name}' changed from {expected_object_id_hash:#010x} to {actual:#010x}. "
f"This will cause users to lose stored preferences!"
)
# =============================================================================
# Test: Legacy preference key computation formula
# =============================================================================
def compute_legacy_preference_key(
entity_name: str, version: int = 0, device_id: int = 0
) -> int:
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
This is the key existing devices have data stored under. Slot-based backends
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
"""
object_id_hash = fnv1_hash_object_id(entity_name)
preference_hash = object_id_hash ^ device_id
key = preference_hash ^ version
return key & 0xFFFFFFFF
# Restore state version constants from ESPHome components
# These MUST match the RESTORE_STATE_VERSION values in the C++ code
FAN_RESTORE_STATE_VERSION = 0x71700ABA # From fan/fan.cpp
CLIMATE_RESTORE_STATE_VERSION = 0x848EA6AD # From climate/climate.cpp
@pytest.mark.parametrize(
("entity_name", "version", "device_id", "expected_key"),
[
# No version, main device (key equals the plain object_id hash)
("Test Sensor", 0, 0, 0x5D74FA46),
("Light", 0, 0, 0x735CF023),
# Restore state versions on the main device
("Ceiling Fan", FAN_RESTORE_STATE_VERSION, 0, 0x157DE5BA),
("HVAC", CLIMATE_RESTORE_STATE_VERSION, 0, 0x59E6E526),
# Sub-devices: same entity name on different devices gets different keys
("Light", 0, 1, 0x735CF022),
("Fan", FAN_RESTORE_STATE_VERSION, 0xABCD, 0x37FFC6F7),
],
)
def test_legacy_preference_key_computation(
entity_name: str, version: int, device_id: int, expected_key: int
) -> None:
"""Verify legacy preference key computation matches expected values.
This test ensures the formula doesn't change, which would break both slot-based
preference storage and the migration source keys on key-lookup backends.
"""
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
assert actual_key == expected_key, (
f"Preference key for '{entity_name}' (version={version:#x}, device_id={device_id}) "
f"changed from {expected_key:#010x} to {actual_key:#010x}. "
f"This will cause users to lose stored preferences!"
)
# =============================================================================
# Test: fnv1_hash_name produces stable entity keys (raw name, UTF-8 bytes)
# =============================================================================
@pytest.mark.parametrize(
("entity_name", "expected_key"),
[
# ASCII names
("Temperature Sensor", 0x801C3665),
("LED Strip", 0xD5C7B082),
("Garage Door", 0x2D70E086),
("Relay", 0x565177C4),
# Raw names are case and space sensitive, unlike the old object_id hash
("temperature sensor", 0xF9F431E5),
# Non-ASCII names hash their UTF-8 bytes and stay distinct
("Датчик открытия", 0x001861C1),
("温度", 0x8EDF61C9),
("Température", 0x531A74AA),
# Empty name hashes to the FNV-1 offset basis
("", 0x811C9DC5),
],
)
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
"""Verify fnv1_hash_name produces stable entity keys.
CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to
API clients and is the new preference key base; changing the algorithm
would break state routing and lose stored preferences.
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
"""
actual = fnv1_hash_name(entity_name)
assert actual == expected_key, (
f"Entity key for '{entity_name}' changed from {expected_key:#010x} to {actual:#010x}. "
f"This breaks state routing and stored preferences!"
)
def test_fnv1_hash_name_matches_utf8_byte_hash() -> None:
"""Verify fnv1_hash_name hashes the UTF-8 encoded bytes of the name."""
name = "Température 温度"
hash_value = FNV1_OFFSET_BASIS
for byte in name.encode("utf-8"):
hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF
hash_value ^= byte
assert fnv1_hash_name(name) == hash_value
+8 -10
View File
@@ -4,12 +4,13 @@ from __future__ import annotations
import re
import socket
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, patch
from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
import pytest
from esphome.async_thread import AsyncDispatchTimeout
from esphome.core import EsphomeError
from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver
@@ -116,20 +117,17 @@ def test_async_resolver_generic_exception() -> None:
def test_async_resolver_thread_timeout() -> None:
"""Test timeout when the runner thread doesn't complete in time."""
# Patch AsyncThreadRunner inside esphome.resolver so we never actually
# start a thread and can control the wait return value directly.
fake_runner = MagicMock()
fake_runner.start = MagicMock()
fake_runner.event.wait.return_value = False # simulate timeout
# Patch run_async inside esphome.resolver so we never actually start a
# thread and can simulate the wait timing out.
with (
patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner),
patch("esphome.resolver.hr.async_resolve_host"),
patch(
"esphome.resolver.run_async", side_effect=AsyncDispatchTimeout
) as mock_run,
pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")),
):
AsyncResolver(["test.local"], 6053).resolve()
fake_runner.start.assert_called_once()
mock_run.assert_called_once_with(ANY, timeout=RESOLVE_TIMEOUT + 1.0)
def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None:
+565
View File
@@ -0,0 +1,565 @@
"""Tests for esphome.stacktrace."""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
import re
from unittest.mock import Mock, patch
from hypothesis import given, settings
from hypothesis.strategies import data as st_data, from_regex
import pytest
from esphome import stacktrace
from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
PLATFORM_RP2,
)
from esphome.core import EsphomeError
CONFIG = {"esphome": {"name": "test"}}
# Real dump lines per registered platform; the gate must fire on each.
# "addresses" are decoder-consumed dump lines, "state_markers" open a
# decoder's dump region, and "extra_triggers" fire the gate without a
# decoder pattern (the stored-dump banner). A new decoder declares its
# lines here so drift fails in CI instead of in the field.
CRASH_SAMPLES: dict[str, dict[str, list[str]]] = {
PLATFORM_ESP32: {
"state_markers": [],
"extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"],
"addresses": [
"Backtrace: 0x400d1a2c:0x3ffb1f60 0x400d2a3c:0x3ffb1f80",
"PC : 0x400d1a2c PS : 0x00060330",
"EXCVADDR: 0x40001234",
"MEPC : 0x40380abc RA : 0x40380def",
"MTVAL : 0x40000123",
"last failed alloc call: 40201234(512)",
"BT0: 0x40104960",
],
},
PLATFORM_ESP8266: {
"state_markers": [">>>stack>>>"],
"extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"],
"addresses": [
"epc1=0x40201234 epc2=0x00000000 excvaddr=0x40001234",
"3ffffe10: 40201234 3ffe8410 00000000 40201000",
"PC : 40201234",
"EXCVADDR: 0x40001234",
"BT0: 0x40201234",
"last failed alloc call: 40201234(512)",
"Exception (28):",
],
},
PLATFORM_RP2: {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x10001234 (fault location)"],
},
PLATFORM_NRF52: {
"state_markers": ["Last crash:"],
"addresses": [
# %08x zero-pads even a vector-table PC past the {3,} bound.
"PC=0x00000050 LR=0x00000000",
# Synthetic short form; pins the bound's lower edge.
"PC=0x27a1c LR=0x1e33",
],
},
}
BENIGN_LINES = [
"[I][app:100] hello world",
"[C][wifi:400] BSSID: AA:BB:CC:DD:EE:FF",
"[19:26:11.966][I][main:151]: version 2026.7.0-dev",
"[I][app:102]: Uptime: 12345678 ms",
"[I][app:102]: Uptime: 41234567 ms",
"[V][esp-idf:000]: I (40219876) wifi: connected",
"[D][api:102]: Client connected (40123456)",
"[D][sensor:093]: 'Water meter': Sending state 12345678.00000 L",
# No internal word boundary; the bare-8-hex branch must not fire.
"[I][ota:117]: MD5 of binary: d41d8cd98f00b204e9800998ecf8427e",
# Short 0x tokens (BLE handles); the 3-digit minimum keeps them out.
"[D][ble:200]: Connection handle 0x1F, MTU 23",
"[C][network:600]: IPv6: fe80::1a2b:3c4d:5e6f:7a8b",
"[C][ota:097]: Version: 2026.7.0",
]
GATE_PARAMS = [
pytest.param(platform, line, True, id=f"{platform}-{kind}-{n}")
for platform, samples in CRASH_SAMPLES.items()
for kind in ("addresses", "state_markers", "extra_triggers")
for n, line in enumerate(samples.get(kind, []))
] + [
pytest.param(platform, line, False, id=f"benign-{platform}-{n}")
for platform in CRASH_SAMPLES
for n, line in enumerate(BENIGN_LINES)
]
@pytest.mark.parametrize(("platform", "line", "should_fire"), GATE_PARAMS)
def test_platform_gate(platform: str, line: str, should_fire: bool) -> None:
gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform])
assert bool(gate.search(line)) is should_fire
def test_gates_are_platform_scoped() -> None:
"""Another platform's markers must not fire an esp32 session's gate."""
esp32_gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[PLATFORM_ESP32])
for line in (
">>>stack>>>",
"Last crash:",
"Exception (28):",
"3ffffe10: 40201234 3ffe8410 00000000 40201000",
):
assert not esp32_gate.search(line)
def _top_level_branches(pattern: str) -> list[str]:
"""Split a regex source on alternations outside groups and classes."""
branches: list[str] = []
depth = 0
in_class = False
esc = False
start = 0
for i, ch in enumerate(pattern):
if esc:
esc = False
elif ch == "\\":
esc = True
elif in_class:
in_class = ch != "]"
elif ch == "[":
in_class = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif ch == "|" and depth == 0:
branches.append(pattern[start:i])
start = i + 1
branches.append(pattern[start:])
return branches
@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES))
def test_every_gate_branch_is_exercised(platform: str) -> None:
"""Every gate branch must be hit by a sample; the superset checks
stay green when a typoed alternation matches nothing.
"""
samples = CRASH_SAMPLES[platform]
lines = [line for kind in samples for line in samples[kind]]
branches = _top_level_branches(stacktrace.platform_hooks.STACKTRACE_GATES[platform])
assert len(branches) > 1
for branch in branches:
assert any(re.search(branch, line) for line in lines), (
f"no {platform} sample exercises gate branch {branch!r}; add one "
"or drop the dead branch"
)
# In-tree sources that print each marker literal the gates key on;
# esp8266's >>>stack>>> comes from the Arduino core, outside this tree.
FIRMWARE_MARKER_SOURCES = {
"CRASH DETECTED ON PREVIOUS BOOT": (
"esphome/components/esp32/crash_handler.cpp",
"esphome/components/esp8266/crash_handler.cpp",
"esphome/components/rp2/crash_handler.cpp",
),
"Last crash:": ("esphome/components/logger/logger_zephyr.cpp",),
}
def test_gate_markers_match_firmware_output() -> None:
"""A reworded firmware banner must fail here, not in the field;
every regex-level guard stays green when the C++ side drifts.
"""
root = Path(__file__).parents[2]
for marker, sources in FIRMWARE_MARKER_SOURCES.items():
for source in sources:
text = (root / source).read_text(encoding="utf-8")
assert marker in text, (
f"{source} no longer prints {marker!r}; update the gates and "
"samples to the new banner"
)
def test_crash_samples_cover_registry() -> None:
"""A newly registered decoder must come with a non-empty gate sample."""
assert set(CRASH_SAMPLES) == set(stacktrace.platform_hooks.STACKTRACE_GATES)
assert set(stacktrace.platform_hooks.STACKTRACE_GATES) == set(
stacktrace.platform_hooks.PLATFORM_HOOKS["process_stacktrace"]
)
assert all(samples["addresses"] for samples in CRASH_SAMPLES.values())
# The stacktrace pattern constants each decoder module exports. The
# samples and these patterns must cover each other, so an edit on either
# side fails the guards below instead of quietly widening the gap
# between the gate and the decoders.
DECODER_PATTERNS: dict[str, list[str]] = {
PLATFORM_ESP32: [
"STACKTRACE_ESP32_PC_RE",
"STACKTRACE_ESP32_EXCVADDR_RE",
"STACKTRACE_ESP32_C3_PC_RE",
"STACKTRACE_ESP32_C3_RA_RE",
"STACKTRACE_ESP32_C3_MTVAL_RE",
"STACKTRACE_BAD_ALLOC_RE",
"STACKTRACE_ESP32_BACKTRACE_RE",
"STACKTRACE_ESP32_BACKTRACE_PC_RE",
"STACKTRACE_ESP32_CRASH_BT_RE",
],
PLATFORM_ESP8266: [
"STACKTRACE_ESP8266_EXCEPTION_TYPE_RE",
"STACKTRACE_ESP8266_PC_RE",
"STACKTRACE_ESP8266_EXCVADDR_RE",
"STACKTRACE_ESP8266_CRASH_PC_RE",
"STACKTRACE_ESP8266_CRASH_EXCVADDR_RE",
"STACKTRACE_ESP8266_CRASH_BT_RE",
"STACKTRACE_BAD_ALLOC_RE",
"STACKTRACE_ESP8266_BACKTRACE_PC_RE",
],
PLATFORM_RP2: ["_CRASH_RE", "_CRASH_ADDR_RE"],
PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"],
}
# Declared decoder patterns whose language the gate deliberately does
# not cover: bare stack-dump words, where the gate keys on the dump
# line's 3ff... stack address instead and a lone letter-free word never
# appears outside a dump region whose other lines already fired.
GATE_EXEMPT_PATTERNS = {
"STACKTRACE_ESP32_BACKTRACE_PC_RE",
"STACKTRACE_ESP8266_BACKTRACE_PC_RE",
}
@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES))
def test_platform_declarations_match_decoder(platform: str) -> None:
r"""Samples, declared patterns, and the decoder must agree.
Checks: declared patterns exist, samples and patterns cover each
other, no stacktrace pattern is undeclared, markers open the dump
region, and a state-setting decoder declares a marker.
Known blind spots: the catch-all backtrace patterns can satisfy the
sample direction alone; the undeclared sweep keys off naming; the
state-gating check is a textual heuristic (pinned against
respelling by the declared-markers direction); a second opening
marker beside a declared one passes unnoticed; and the generative
guard draws full matches, so trailing word characters defeating the
pointer branch's ``\b`` are invisible to it.
"""
module = importlib.import_module(f"esphome.components.{platform}")
patterns: dict[str, re.Pattern] = {}
for name in DECODER_PATTERNS[platform]:
pattern = getattr(module, name, None)
if pattern is None:
pytest.fail(
f"{platform} no longer defines {name}; update DECODER_PATTERNS "
"and CRASH_SAMPLES together"
)
patterns[name] = pattern
lines = (
CRASH_SAMPLES[platform]["state_markers"] + CRASH_SAMPLES[platform]["addresses"]
)
for line in CRASH_SAMPLES[platform]["addresses"]:
assert any(p.search(line) for p in patterns.values()), (
f"{line!r} no longer matches any {platform} decoder pattern; "
"update CRASH_SAMPLES and re-derive the gate"
)
for name, pattern in patterns.items():
assert any(pattern.search(line) for line in lines), (
f"no sample exercises {platform}.{name}; add one so the gate "
"provably covers it"
)
undeclared = [
name
for name, value in vars(module).items()
if isinstance(value, re.Pattern)
and ("STACKTRACE" in name or name.startswith("_CRASH"))
and name not in DECODER_PATTERNS[platform]
]
assert not undeclared, (
f"{platform} gained stacktrace patterns {undeclared}; declare them in "
"DECODER_PATTERNS with samples"
)
for marker in CRASH_SAMPLES[platform]["state_markers"]:
assert module.process_stacktrace(CONFIG, marker, False) is True, (
f"{marker!r} no longer opens {platform}'s dump region; update "
"state_markers to the line the decoder actually keys on"
)
# Textual heuristic, deliberately one-directional: a state-gated
# decoder must declare a marker. The reverse (a stateless decoder
# declaring none) is not asserted; an unrelated "return True" added
# to a decoder would turn it into a false failure.
source = inspect.getsource(module.process_stacktrace)
sets_state = "return True" in source or "backtrace_state = True" in source
if CRASH_SAMPLES[platform]["state_markers"]:
# The heuristic fails open on a respelling (return bool(...));
# pinning it against the decoders known to be state-gated today
# turns a silent disarm into a failure that names the fix.
assert sets_state, (
f"{platform}.process_stacktrace declares state_markers but the "
"state-gating heuristic no longer recognises it; update the "
"spelling list in this test"
)
if sets_state:
assert CRASH_SAMPLES[platform]["state_markers"], (
f"{platform}.process_stacktrace is state-gated but declares no "
"state_markers; the gate cannot promise to open its dump region"
)
@pytest.mark.parametrize(
("platform", "name"),
[
(platform, name)
for platform, names in DECODER_PATTERNS.items()
for name in names
if name not in GATE_EXEMPT_PATTERNS
],
)
@given(data=st_data())
@settings(max_examples=25, deadline=None)
def test_address_gate_covers_decoder_pattern_languages(
platform: str, name: str, data
) -> None:
"""Each platform's gate must be a superset of its decoder patterns;
generated inputs catch a widened decoder the finite samples miss.
"""
pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name)
example = data.draw(from_regex(pattern, fullmatch=True))
gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform])
assert gate.search(example), (
f"{platform}.{name} accepts {example!r} but the {platform} gate does "
"not fire; decoding would silently never start on that form"
)
def _run(
handler,
platform: str = PLATFORM_ESP32,
lines: tuple[str, ...] = ("PC: 0x4010496e",),
) -> stacktrace.LogLineProcessor:
"""Processor with the resolver stubbed, fed the given lines."""
with patch.object(
stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler
):
processor = stacktrace.LogLineProcessor(CONFIG, platform)
for line in lines:
processor.process_line(line)
return processor
def _fed(handler) -> list[str]:
return [call.args[1] for call in handler.call_args_list]
def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]:
return [r.message for r in caplog.records if r.levelname == "WARNING"]
def test_decoder_contains_failures_and_short_circuits() -> None:
"""One decode failure is contained and never retried; a retry per
backtrace line would stall streaming on a failing subprocess.
"""
handler = Mock(side_effect=EsphomeError("no idedata"))
processor = _run(
handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e", "BT1: 0x401049aa")
)
assert handler.call_count == 1
assert processor.backtrace_state is False
def test_decoder_swallows_os_error_with_remediation_hint(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An OSError (missing build tree) is the user's environment, not a
decoder bug; it must keep the recompile hint.
"""
handler = Mock(
side_effect=FileNotFoundError(2, "No such file or directory", "/build")
)
processor = _run(handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e"))
assert handler.call_count == 1
assert processor.backtrace_state is False
warnings = _warnings(caplog)
assert any("esphome compile" in m for m in warnings)
assert not any("this is a bug" in m for m in warnings)
def test_decoder_warning_uses_fallback_for_empty_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A bare EsphomeError must not render as empty parens."""
_run(Mock(side_effect=EsphomeError()))
warnings = _warnings(caplog)
assert any("build artifacts not found locally" in m for m in warnings)
assert not any("()" in m for m in warnings)
def test_decoder_bug_with_empty_message_names_the_type(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A decoder bug says so instead of sending the user down the
dead-end recompile path.
"""
_run(Mock(side_effect=IndexError()))
warnings = _warnings(caplog)
assert any("IndexError" in m and "this is a bug" in m for m in warnings)
assert not any("esphome compile" in m for m in warnings)
def test_decoder_bug_warning_keeps_the_type_with_a_message(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The type must survive a non-empty message; a bare KeyError message
like 'prog_path' reads as a raised string in a bug report paste.
"""
_run(Mock(side_effect=KeyError("prog_path")))
warnings = _warnings(caplog)
assert any("KeyError: 'prog_path'" in m for m in warnings)
def test_marker_then_address_threads_state() -> None:
"""A state marker resolves the decoder live and threads state to
the following stack words.
"""
handler = Mock(side_effect=[True, True])
processor = _run(
handler,
platform=PLATFORM_ESP8266,
lines=(">>>stack>>>", "3ffffe10: 40201234 3ffe8410 00000000 40201000"),
)
assert _fed(handler) == [
">>>stack>>>",
"3ffffe10: 40201234 3ffe8410 00000000 40201000",
]
assert handler.call_args_list[0].args[2] is False
assert handler.call_args_list[1].args[2] is True
assert processor.backtrace_state is True
def test_lines_before_the_gate_never_reach_the_decoder() -> None:
"""Benign lines are dropped, not buffered."""
handler = Mock(return_value=False)
quiet = tuple(f"quiet line {n}" for n in range(12))
_run(handler, lines=quiet + ("PC: 0x4010496e",))
assert _fed(handler) == ["PC: 0x4010496e"]
def test_processor_resolves_lazily_on_address_token() -> None:
"""No resolution attempt until a line carries an address token."""
handler = Mock(return_value=False)
with patch.object(
stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler
) as mock_resolve:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32)
processor.process_line("[I][app:100] hello world")
mock_resolve.assert_not_called()
processor.process_line("PC: 0x40104960")
mock_resolve.assert_called_once_with(PLATFORM_ESP32)
# Later lines feed the resolved handler directly, no re-resolution.
processor.process_line("[I][app:101] back to normal")
mock_resolve.assert_called_once()
assert _fed(handler) == ["PC: 0x40104960", "[I][app:101] back to normal"]
def test_processor_unexpected_resolution_error_disables_decoding(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Resolution is inside the containment guarantee like everything else."""
with patch.object(
stacktrace.platform_hooks,
"get_stacktrace_handler",
side_effect=OSError("filesystem went away"),
) as mock_resolve:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32)
processor.process_line("PC: 0x40104960")
processor.process_line("BT0: 0x40104960")
mock_resolve.assert_called_once()
warnings = _warnings(caplog)
assert len(warnings) == 1
assert "could not be loaded" in warnings[0]
assert processor.backtrace_state is False
def test_processor_import_failure_disables_decoding(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A broken platform package degrades once instead of raising."""
caplog.set_level("INFO", logger="esphome.platform_hooks")
with patch.object(
stacktrace.platform_hooks,
"import_module",
Mock(side_effect=ImportError("broken install")),
) as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32)
processor.process_line("PC: 0x40104960")
processor.process_line("BT0: 0x40104960")
mock_import.assert_called_once()
assert "Stacktrace analysis is unavailable" in caplog.text
assert "broken install" in caplog.text
assert processor.backtrace_state is False
def test_processor_registry_miss_disables_at_construction(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Platforms the registry proves have no analyzer disable up front.
The unavailable notice fires at session start (as it always did) and
the per-line gate never runs.
"""
caplog.set_level("INFO", logger="esphome.platform_hooks")
with patch.object(stacktrace.platform_hooks, "import_module") as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX)
processor.process_line("PC: 0x40104960")
mock_import.assert_not_called()
assert "Stacktrace analysis is unavailable" in caplog.text
assert processor.backtrace_state is False
def test_external_platform_resolves_at_construction(
caplog: pytest.LogCaptureFixture,
) -> None:
"""External platforms resolve eagerly; the gates cannot speak for an
external decoder and the import belongs off the streaming callback.
"""
caplog.set_level("INFO", logger="esphome.platform_hooks")
module = type("ExternalPlatform", (), {}) # no process_stacktrace
with patch.object(
stacktrace.platform_hooks,
"import_module",
Mock(return_value=module),
) as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, "my_external_chip")
mock_import.assert_called_once()
assert "Stacktrace analysis is unavailable" in caplog.text
processor.process_line("PC: 0x40104960")
mock_import.assert_called_once()
assert processor.backtrace_state is False
+161 -2
View File
@@ -1,3 +1,5 @@
from collections import ChainMap
from fnmatch import fnmatchcase
import logging
from pathlib import Path
from typing import Any
@@ -368,7 +370,7 @@ def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None:
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
# Snapshot is populated.
assert result.user_config is not None
@@ -391,7 +393,7 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
assert result.user_config is not None
# preload_core_config injected build_path onto the validated config.
@@ -402,6 +404,32 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No
assert result["esphome"] is not result.user_config["esphome"]
def test_validate_config_snapshot_without_substitutions(tmp_path: Path) -> None:
"""The snapshot works for configs that have no substitutions block."""
test_config = _get_test_minimal_valid_config(tmp_path)
del test_config[CONF_SUBSTITUTIONS]
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
assert result.user_config is not None
assert CONF_SUBSTITUTIONS not in result.user_config
assert result.user_config["esphome"] == {"name": "test_device"}
def test_validate_config_skips_user_config_snapshot_by_default(
tmp_path: Path,
) -> None:
"""Without ``snapshot_user_config`` the deep copy is skipped entirely;
only ``esphome config --no-defaults`` needs the snapshot and the copy is
too expensive to take on every load.
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
assert result.user_config is None
def test_merge_config_preserves_ordered_dict() -> None:
"""Test that merge_config preserves OrderedDict type.
@@ -961,3 +989,134 @@ def test_remote_package_scalar_yaml_raises_helpful_error(
msg = str(exc_info.value)
assert "mapping at the top level" in msg
assert "file1.yaml" in msg
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param("wifi.yaml", ["wifi.yaml"], id="literal_passthrough"),
pytest.param(
"keys/${system_name}.yaml", ["keys/*.yaml"], id="embedded_substitution"
),
pytest.param(
"network/${eth_model}/config.yaml",
["network/*/config.yaml"],
id="directory_substitution",
),
pytest.param(
"device-$platform.yaml", ["device-*.yaml"], id="unbraced_substitution"
),
pytest.param("${a}${b}.yaml", ["*.yaml"], id="adjacent_wildcards_collapse"),
pytest.param(
'${ "a.yaml" if x else "../empty.yaml" }',
["a.yaml", "../empty.yaml"],
id="conditional_literals",
),
pytest.param(
'pre-${ "a" if c else "b" }.yaml',
["pre-a.yaml", "pre-b.yaml"],
id="conditional_spliced",
),
pytest.param(
'${ "x.yaml" if a else ("y.yaml" if b else "z.yaml") }',
["x.yaml", "y.yaml", "z.yaml"],
id="nested_conditional",
),
pytest.param(
'${ "same.yaml" if x else "same.yaml" }',
["same.yaml"],
id="duplicate_literals_dedupe",
),
pytest.param('${ "a.yaml" if x }', ["a.yaml"], id="conditional_no_else"),
pytest.param(
'${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"'
' if enable_bluetooth_proxy else "../empty.yaml" }',
["NO BLUETOOTH SUPPORT ON ESP8266.yaml", "../empty.yaml"],
id="issue_17650_verbatim",
),
pytest.param(
'${ "" if x else "b.yaml" }', ["b.yaml"], id="empty_literal_dropped"
),
pytest.param(
"keys\\${system_name}.yaml",
["keys\\*.yaml"],
id="backslash_separator",
),
pytest.param(
'${ "it\'s.yaml" if x else "b.yaml" }',
["it's.yaml", "b.yaml"],
id="apostrophe_in_literal",
),
pytest.param(
'${ "a-${x}.yaml" if c else "b.yaml" }',
["a-*.yaml", "b.yaml"],
id="substitution_inside_literal",
),
pytest.param("sensor [${x}].yaml", ["sensor [[]*].yaml"], id="bracket_escaped"),
pytest.param(
"config?${x}.yaml", ["config[?]*.yaml"], id="question_mark_escaped"
),
pytest.param(
"../${x}/config.yaml", ["../*/config.yaml"], id="ascending_directory"
),
pytest.param("${file}", [], id="bare_variable_dropped"),
pytest.param("../${file}", [], id="ascending_bare_variable_dropped"),
pytest.param(
'${ name ~ ".yaml" }', [".yaml"], id="dynamic_concat_extracts_literal"
),
pytest.param("${ if }", [], id="no_literal_expression_dropped"),
pytest.param(
"<% if x %>a.yaml<% endif %>", ["*a.yaml*"], id="block_statement_globs"
),
],
)
def test_include_candidate_patterns(value: str, expected: list[str]) -> None:
"""Templated include paths expand to glob patterns and branch literals."""
assert substitutions.include_candidate_patterns(value) == expected
@pytest.mark.parametrize(
("template", "variables"),
[
pytest.param(
"keys/${system_name}.yaml", {"system_name": "esp-buero"}, id="embedded"
),
pytest.param("device-$platform.yaml", {"platform": "esp32"}, id="unbraced"),
pytest.param(
"network/${eth_model}/config.yaml", {"eth_model": "eth01"}, id="directory"
),
pytest.param(
'${ "NO BT.yaml" if bt else "../empty.yaml" }',
{"bt": True},
id="conditional_true",
),
pytest.param(
'${ "NO BT.yaml" if bt else "../empty.yaml" }',
{"bt": False},
id="conditional_false",
),
pytest.param('pre-${ "a" if c else "b" }.yaml', {"c": True}, id="spliced"),
pytest.param("${a}${b}.yaml", {"a": "x", "b": "y"}, id="adjacent"),
pytest.param("sensor [${x}].yaml", {"x": "a"}, id="bracket"),
],
)
def test_include_candidate_patterns_cover_real_expansion(
template: str, variables: dict[str, Any]
) -> None:
"""
Lockstep pin against the real substitution machinery.
include_candidate_patterns mirrors _expand_substitutions without
variable values (the evaluator returns the one selected branch, so it
cannot enumerate candidates itself); this asserts every filename the
real pass resolves is covered by a candidate pattern, so a change to
reference syntax or expansion order breaks here instead of silently
dropping files from bundles.
"""
resolved = str(
substitutions._expand_substitutions(
template, [], ChainMap(variables), True, None
)
)
patterns = substitutions.include_candidate_patterns(template)
assert any(fnmatchcase(resolved, p) or resolved == p for p in patterns)
+365 -13
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import io
import logging
from pathlib import Path
import subprocess
import sys
@@ -13,6 +14,8 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome import util
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE
def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None:
@@ -422,6 +425,199 @@ def _make_redirect(
return redirect, buf
def test_redirect_text_flushes_so_piped_output_streams() -> None:
"""Regression: in-process esptool progress must reach the pipe right away.
``run_external_command`` runs esptool inside our own process, so its
progress output goes through ``RedirectText.write``. That used to be
flushed only because ``colorama.init()`` wrapped stdout in a stream that
flushed after every write.
"""
buf = io.BytesIO()
piped_stream = io.TextIOWrapper(
buf, encoding="utf-8", newline="\n", line_buffering=False
)
redirect = util.RedirectText(piped_stream)
redirect.write("Writing at 0x00010000 (50%)\r")
# No explicit flush here on purpose: RedirectText has to do it.
assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r"
@pytest.mark.parametrize(
"break_char",
["\x0c", "\x0b", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"],
ids=["formfeed", "vtab", "fs", "gs", "rs", "nel", "lsep", "psep"],
)
def test_redirect_text_keeps_output_after_an_exotic_break_character(
break_char: str,
) -> None:
r"""Only ``\n`` and ``\r`` end a line; the rest is ordinary text.
``str.splitlines`` treats all of these as line breaks. Splitting on them
used to strand the fragment in the buffer and drop every complete line
that came after it, which for a form feed in toolchain output meant
losing the rest of the build log.
"""
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write(f"first{break_char}second\nthird\n")
assert buf.getvalue() == f"first{break_char}second\nthird\n"
def test_redirect_text_treats_crlf_as_one_terminator() -> None:
r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line."""
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("one\r\ntwo\rthree\nfour")
# "four" has no terminator yet, so it is held back.
assert buf.getvalue() == "one\r\ntwo\rthree\n"
redirect.drain()
assert buf.getvalue() == "one\r\ntwo\rthree\nfour\n"
def test_redirect_text_drain_releases_held_partial_line() -> None:
"""A last line with no terminator must still reach the user.
A tool that dies part way through a line leaves that text in the buffer,
and it is usually the message saying what went wrong.
"""
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("FATAL: ld returned 1 exit status")
# Still held: no terminator has arrived.
assert buf.getvalue() == ""
redirect.drain()
assert buf.getvalue() == "FATAL: ld returned 1 exit status\n"
def test_redirect_text_drain_still_applies_the_filter() -> None:
"""Releasing a held line does not smuggle noise past the filter."""
redirect, buf = _make_redirect(filter_lines=["Verbose mode can be enabled"])
redirect.write("Verbose mode can be enabled")
redirect.drain()
assert buf.getvalue() == ""
def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None:
"""Draining twice, or with an empty buffer, writes nothing extra."""
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("complete line\n")
redirect.drain()
redirect.drain()
assert buf.getvalue() == "complete line\n"
def test_flash_error_help_is_quiet_when_core_is_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: reading the platform used to raise in the runner."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
assert util.get_esp32_arduino_flash_error_help() is None
def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The parent tells the subprocess what it cannot work out for itself."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1")
help_msg = util.get_esp32_arduino_flash_error_help()
assert help_msg is not None
assert "esp-idf" in help_msg
def test_is_esp32_arduino_build_raises_on_a_half_filled_core(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A half filled in CORE is a bug, so it must raise, not fall back."""
monkeypatch.setattr(CORE, "data", {KEY_CORE: {}})
monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1")
with pytest.raises(KeyError):
util.is_esp32_arduino_build()
@pytest.mark.parametrize(
("platform", "framework", "expected"),
[
("esp32", "arduino", True),
("esp32", "esp-idf", False),
("esp8266", "arduino", False),
],
)
def test_is_esp32_arduino_build_from_a_configured_core(
monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool
) -> None:
"""With CORE set up, it is the source of truth and the env var is ignored."""
monkeypatch.setattr(
CORE,
"data",
{KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}},
)
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
assert util.is_esp32_arduino_build() is expected
def test_redirect_text_survives_a_flash_error_without_core(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The overflow line goes through even from a process with no CORE."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("Error: The program size is greater than maximum allowed\n")
assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n"
def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None:
"""An out-of-flash error gets the how-to-fix note appended."""
monkeypatch.setattr(
util, "get_esp32_arduino_flash_error_help", lambda: "TIP: switch to esp-idf\n"
)
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("Error: The program size is greater than maximum allowed\n")
assert "Error: The program size" in buf.getvalue()
assert "TIP: switch to esp-idf" in buf.getvalue()
def test_redirect_text_skips_flash_size_help_on_other_platforms(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The note is ESP32-with-Arduino only, so elsewhere the line stands alone."""
monkeypatch.setattr(util, "get_esp32_arduino_flash_error_help", lambda: None)
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("Error: The program size is greater than maximum allowed\n")
assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n"
def test_redirect_text_callback_called_on_matching_line() -> None:
"""Test that a line callback is called and its output is written."""
results: list[str] = []
@@ -551,6 +747,140 @@ def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> N
assert "CALLBACK FIRED" in captured.out
def test_run_external_command_drains_partial_line(
capsys: pytest.CaptureFixture,
) -> None:
"""A command that stops mid line still shows that line.
esptool runs in-process here, so a message it writes without a trailing
newline would otherwise be dropped when the streams are put back.
"""
def fake_main() -> int:
print("A fatal error occurred: no serial data", end="")
return 1
rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"])
assert rc == 1
assert "A fatal error occurred: no serial data" in capsys.readouterr().out
def test_run_external_command_drains_on_early_exit(
capsys: pytest.CaptureFixture,
) -> None:
"""The drain also happens when the command exits through ``sys.exit``."""
def fake_main() -> int:
print("Fatal: bailing out", end="")
sys.exit(3)
rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"])
assert rc == 3
assert "Fatal: bailing out" in capsys.readouterr().out
def test_run_external_command_capture_stdout_has_nothing_to_drain() -> None:
"""With ``capture_stdout`` there is nothing held to write out.
The stdout wrapper still gets built, but ``sys.stdout`` is replaced by
the capture buffer right after, so the wrapper never sees a write and
draining it does nothing.
"""
def fake_main() -> int:
print("captured output", end="")
return 0
out = util.run_external_command(
fake_main, "fake", capture_stdout=True, filter_lines=["ignore me"]
)
assert out == "captured output"
def test_run_external_command_survives_a_command_that_swaps_stdout(
capsys: pytest.CaptureFixture,
) -> None:
"""Draining must not depend on what the command left in ``sys.stdout``.
A command is free to replace the stream; reaching for ``drain`` on
whatever it left there would raise from the cleanup path and bury the
real exit code.
"""
def fake_main() -> int:
print("before the swap", end="")
sys.stdout = io.StringIO()
sys.exit(7)
rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"])
assert rc == 7
assert "before the swap" in capsys.readouterr().out
def test_drain_reports_the_lost_line_instead_of_raising(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A broken stream during cleanup is reported, not raised.
The warning carries the held text, because the stream we were asked to
write it to is the one that just failed.
"""
caplog.set_level(logging.WARNING, logger=util.__name__)
out = MagicMock()
out.write.side_effect = BrokenPipeError("pipe is gone")
redirect = util.RedirectText(out, filter_lines=["ignore me"])
redirect.write("FATAL: ld returned 1 exit status")
redirect.drain()
assert "pipe is gone" in caplog.text
assert "FATAL: ld returned 1 exit status" in caplog.text
def test_drain_lets_other_errors_through() -> None:
"""Only an unusable stream is tolerated; a bug still has to be visible."""
def broken_callback(line: str) -> str | None:
raise TypeError("a line callback is broken")
redirect, _buf = _make_redirect(line_callbacks=[broken_callback])
redirect.write("a line with no terminator")
with pytest.raises(TypeError):
redirect.drain()
def test_run_external_command_drains_stderr_even_if_stdout_drain_raises(
capsys: pytest.CaptureFixture,
) -> None:
"""One stream failing must not strand the other's held line.
``drain`` deliberately lets anything that is not a stream error through,
so a broken line callback would otherwise skip the stderr drain and take
that line down with it.
"""
def broken_on_stdout(line: str) -> str | None:
if "stdout" in line:
raise TypeError("a line callback is broken")
return None
def fake_main() -> int:
print("stdout partial", end="")
print("stderr FATAL: the real reason", end="", file=sys.stderr)
return 0
with pytest.raises(TypeError):
util.run_external_command(fake_main, "fake", line_callbacks=[broken_on_stdout])
# The bug still surfaces, but stderr's held line was written first.
assert "stderr FATAL: the real reason" in capsys.readouterr().err
def test_run_external_process_line_callbacks() -> None:
"""Test that run_external_process passes line_callbacks to RedirectText."""
results: list[str] = []
@@ -561,7 +891,7 @@ def test_run_external_process_line_callbacks() -> None:
return "PROCESS CALLBACK\n"
return None
with patch("esphome.util.subprocess.run") as mock_run:
with patch("subprocess.run") as mock_run:
def run_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
# Simulate subprocess writing to the stdout RedirectText
@@ -635,7 +965,7 @@ def test_detect_rp2040_bootsel_found() -> None:
"""Test BOOTSEL device detection when device is present."""
mock_result = MagicMock()
mock_result.stdout = b"Device Information\n type: RP2040\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
with patch("subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 1
assert result.permission_error is False
@@ -645,7 +975,7 @@ def test_detect_rp2040_bootsel_multiple() -> None:
"""Test BOOTSEL detection with multiple devices."""
mock_result = MagicMock()
mock_result.stdout = b"type: RP2040\ntype: RP2350\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
with patch("subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 2
assert result.permission_error is False
@@ -658,7 +988,7 @@ def test_detect_rp2040_bootsel_none() -> None:
b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n"
)
mock_result.stderr = b""
with patch("esphome.util.subprocess.run", return_value=mock_result):
with patch("subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is False
@@ -675,7 +1005,7 @@ def test_detect_rp2040_bootsel_permission_error() -> None:
b"but picotool was unable to connect. "
b"Maybe try 'sudo' or check your permissions.\n"
)
with patch("esphome.util.subprocess.run", return_value=mock_result):
with patch("subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is True
@@ -686,7 +1016,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None:
mock_result = MagicMock()
mock_result.stdout = b""
mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
with patch("subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is True
@@ -694,7 +1024,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None:
def test_detect_rp2040_bootsel_oserror() -> None:
"""Test BOOTSEL detection handles OSError."""
with patch("esphome.util.subprocess.run", side_effect=OSError("not found")):
with patch("subprocess.run", side_effect=OSError("not found")):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is False
@@ -703,7 +1033,7 @@ def test_detect_rp2040_bootsel_oserror() -> None:
def test_detect_rp2040_bootsel_timeout() -> None:
"""Test BOOTSEL detection handles timeout."""
with patch(
"esphome.util.subprocess.run",
"subprocess.run",
side_effect=subprocess.TimeoutExpired("picotool", 10),
):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
@@ -717,7 +1047,6 @@ class TestSafePrint:
@pytest.fixture(autouse=True)
def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Default ``CORE.dashboard`` to False so each test starts hermetic."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", False)
@@ -739,12 +1068,36 @@ class TestSafePrint:
monkeypatch: pytest.MonkeyPatch,
) -> None:
r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", True)
util.safe_print("\033[0;32mhi\033[0m")
assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n"
def test_flushes_so_piped_output_streams(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression: each line must reach the OS pipe right away.
The dashboard runs ``esphome logs`` with stdout as a pipe, which
Python block buffers at 8 KiB. Log lines used to be flushed only
because ``colorama.init()`` wrapped stdout in a stream that flushed
after every write; once that wrapping was skipped for dashboard runs
the lines sat in the buffer and the log view stayed empty until
enough output piled up to fill it.
"""
buf = io.BytesIO()
# newline="\n" keeps Windows from rewriting the terminator to "\r\n";
# this test is about flushing, not about line endings.
piped_stream = io.TextIOWrapper(
buf, encoding="utf-8", newline="\n", line_buffering=False
)
monkeypatch.setattr(sys, "stdout", piped_stream)
util.safe_print("live log line")
# No explicit flush here on purpose: safe_print has to do it.
assert buf.getvalue() == b"live log line\n"
def test_fallback_writes_string_not_bytes_repr(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -764,7 +1117,7 @@ class TestSafePrint:
monkeypatch.setattr(sys, "stdout", cp1252_stream)
util.safe_print("bars: \u2582\u2584\u2586\u2588 done")
cp1252_stream.flush()
# No explicit flush: the fallback path has to flush too.
output = buf.getvalue().decode("cp1252")
# Output is a clean line, not the bytes repr.
@@ -781,7 +1134,6 @@ class TestSafePrint:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Dashboard ESC escaping + cp1252 fallback compose correctly."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", True)
buf = io.BytesIO()
@@ -789,7 +1141,7 @@ class TestSafePrint:
monkeypatch.setattr(sys, "stdout", cp1252_stream)
util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m")
cp1252_stream.flush()
# No explicit flush: the fallback path has to flush too.
output = buf.getvalue().decode("cp1252")
# Dashboard escaping turned ESC into literal "\033" (5 chars), which
+410 -2
View File
@@ -282,8 +282,54 @@ test: !include_dir_named test_dir
assert ".hidden_dir" not in actual["test"]
def test_include_dir_list(tmp_path: Path) -> None:
"""!include_dir_list loads every .yaml file in the directory as a list."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("key: value_a")
(test_dir / "b.yaml").write_text("key: value_b")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_list test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert len(actual["test"]) == 2
assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"}
def test_include_dir_merge_list(tmp_path: Path) -> None:
"""!include_dir_merge_list concatenates the lists from every .yaml file."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("- item_a1\n- item_a2\n")
(test_dir / "b.yaml").write_text("- item_b1\n")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_merge_list test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"]
def test_include_dir_merge_named(tmp_path: Path) -> None:
"""!include_dir_merge_named merges the mappings from every .yaml file."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("key_a: value_a")
(test_dir / "b.yaml").write_text("key_b: value_b")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_merge_named test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"}
def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None:
"""Test that _find_files works recursively through include_dir_named."""
"""Test that find_files works recursively through include_dir_named."""
# Copy fixture directory to temporary location
src_dir = fixture_path / "yaml_util"
dst_dir = tmp_path / "yaml_util"
@@ -1003,8 +1049,10 @@ class _StubInclude:
load_result: object = None,
raise_on_load: EsphomeError | None = None,
) -> None:
# Default parent lives in a nonexistent directory so unresolved
# stubs never glob real files during candidate expansion.
self.file = Path(file)
self.parent_file = parent_file or Path("/tmp/parent.yaml")
self.parent_file = parent_file or Path("/nonexistent/parent.yaml")
self._unresolved = unresolved
self._load_result = load_result if load_result is not None else {}
self._raise = raise_on_load
@@ -1182,6 +1230,247 @@ def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None:
assert discovered.files.count(wifi_resolved) == 1
def test_discover_user_yaml_files_expands_directory_substitution(
tmp_path: Path,
) -> None:
"""A substitution spanning a directory segment globs across directories."""
_write(tmp_path, "network/eth01/config.yaml", "ethernet:\n")
_write(tmp_path, "network/eth02/config.yaml", "ethernet:\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "network/${eth_model}/config.yaml")
)
resolved = set(discovered.files)
assert (tmp_path / "network/eth01/config.yaml").resolve() in resolved
assert (tmp_path / "network/eth02/config.yaml").resolve() in resolved
def test_discover_user_yaml_files_loads_both_branches_of_issue_conditional(
tmp_path: Path,
) -> None:
"""Both branch files of the issue-17650 conditional load when present,
including the filename with spaces."""
_write(tmp_path, "empty.yaml", "{}\n")
_write(tmp_path, "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml", "api:\n")
_write(
tmp_path,
"boards/esp8266.yaml",
"packages:\n"
' - !include ${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"'
' if enable_bluetooth_proxy else "../empty.yaml" }\n',
)
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "boards/esp8266.yaml")
)
resolved = set(discovered.files)
assert (tmp_path / "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml").resolve() in (
resolved
)
assert (tmp_path / "empty.yaml").resolve() in resolved
def test_discover_user_yaml_files_glob_matches_bracket_filenames(
tmp_path: Path,
) -> None:
"""Glob metacharacters in the literal filename text stay literal."""
_write(tmp_path, "sensor [a].yaml", "api:\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "sensor [${x}].yaml")
)
assert "sensor [a].yaml" in {p.name for p in discovered.files}
def test_discover_user_yaml_files_ascending_glob(tmp_path: Path) -> None:
"""A templated include reaching into a sibling directory via ``..`` globs."""
_write(tmp_path, "shared/common.yaml", "api:\n")
_write(tmp_path, "nodes/dev.yaml", "p: !include ../shared/${x}.yaml\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "nodes/dev.yaml")
)
assert (tmp_path / "shared/common.yaml").resolve() in discovered.files
def test_discover_user_yaml_files_mapping_include_with_vars(tmp_path: Path) -> None:
"""The mapping !include form (file + vars) expands a templated filename."""
_write(tmp_path, "keys/a.yaml", "pin: ${num}\n")
entry = _write(
tmp_path,
"entry.yaml",
"wifi: !include\n file: keys/${n}.yaml\n vars:\n num: 4\n",
)
discovered = discover_user_yaml_files(entry)
assert (tmp_path / "keys/a.yaml").resolve() in discovered.files
def test_discover_user_yaml_files_absolute_templated_include_skipped(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An absolute templated include is skipped gracefully instead of crashing."""
shared = tmp_path / "shared"
_write(tmp_path, "shared/common.yaml", "api:\n")
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, f"{shared}/${{x}}.yaml")
)
assert (shared / "common.yaml").resolve() not in discovered.files
assert any("Cannot glob include pattern" in r.message for r in caplog.records)
def test_discover_user_yaml_files_glob_skips_dollar_named_files(
tmp_path: Path,
) -> None:
"""An on-disk filename containing ``$`` can't load; the glob skips it."""
_write(tmp_path, "keys/a.yaml", "api:\n")
_write(tmp_path, "keys/b$roken.yaml", "api:\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "keys/${n}.yaml")
)
names = {p.name for p in discovered.files}
assert "a.yaml" in names
assert "b$roken.yaml" not in names
def test_discover_user_yaml_files_glob_error_skips_include(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A filesystem error during candidate globbing warns and skips the include."""
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
with (
patch.object(Path, "glob", side_effect=OSError("boom")),
caplog.at_level("DEBUG", logger="esphome.yaml_util"),
):
discovered = discover_user_yaml_files(entry)
assert [p.name for p in discovered.files] == ["entry.yaml"]
matching = [
r.levelname
for r in caplog.records
if "I/O error globbing include pattern" in r.message
]
assert matching == ["WARNING"]
def test_force_load_candidate_failure_warns_by_default(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A broken candidate logs at WARNING outside the discovery re-parse."""
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
force_load_include_files(yaml_util.load_yaml(entry))
matching = [
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
]
assert matching == ["WARNING"]
def test_discover_user_yaml_files_glob_skips_hidden_files(tmp_path: Path) -> None:
"""Candidate globs exclude hidden files, matching ``!include_dir_*``."""
_write(tmp_path, "keys/device-a.yaml", "api:\n")
_write(tmp_path, "keys/.hidden.yaml", "api:\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "keys/${name}.yaml")
)
names = {p.name for p in discovered.files}
assert "device-a.yaml" in names
assert ".hidden.yaml" not in names
def test_discover_user_yaml_files_bare_expression_not_expanded(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A fully dynamic filename never globs the whole directory."""
_write(tmp_path, "sibling.yaml", "api:\n")
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "${file}")
)
assert (tmp_path / "sibling.yaml").resolve() not in discovered.files
assert any(
"Cannot resolve !include" in r.message and r.levelname == "DEBUG"
for r in caplog.records
)
def test_discover_user_yaml_files_self_glob_match_skipped(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A glob whose only match is the including file itself claims nothing."""
entry = _write_entry_including(tmp_path, "${platform}.yaml")
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
discovered = discover_user_yaml_files(entry)
assert [p.name for p in discovered.files] == ["entry.yaml"]
assert any("Cannot resolve !include" in r.message for r in caplog.records)
def test_discover_user_yaml_files_candidate_cycle_terminates(tmp_path: Path) -> None:
"""Mutually glob-matching includes expand finitely and capture both files."""
_write(tmp_path, "sub/a.yaml", "p: !include ${x}.yaml\n")
_write(tmp_path, "sub/b.yaml", "p: !include ${y}.yaml\n")
entry = _write(tmp_path, "entry.yaml", "wifi: !include sub/a.yaml\n")
discovered = discover_user_yaml_files(entry)
names = {p.name for p in discovered.files}
assert names == {"entry.yaml", "a.yaml", "b.yaml"}
def test_discover_user_yaml_files_many_candidates_keep_nested_includes(
tmp_path: Path,
) -> None:
"""Every candidate's nested includes are discovered.
Regression test: the id()-based cycle guard is only safe while every
traversed tree stays alive. Candidate trees used to be freed between
loop iterations, so CPython recycled their addresses and later
candidates' fresh trees were skipped as already seen, silently dropping
their nested includes. Needs several candidates to manifest; two were
not enough to trigger the reuse."""
count = 12
for i in range(count):
_write(
tmp_path, f"keys/k{i}.yaml", f"sensor{i}: !include ../nested/n{i}.yaml\n"
)
_write(tmp_path, f"nested/n{i}.yaml", f"api{i}: true\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "keys/${x}.yaml")
)
names = {p.name for p in discovered.files}
expected = {f"n{i}.yaml" for i in range(count)}
expected |= {f"k{i}.yaml" for i in range(count)}
expected.add("entry.yaml")
assert names == expected
def test_discover_user_yaml_files_bad_candidate_still_tracked(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A matched candidate that fails to parse warns even during discovery,
stays tracked (the load listener fires before parsing), and doesn't block
other candidates."""
_write(tmp_path, "keys/good.yaml", "api:\n")
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "keys/${name}.yaml")
)
resolved = set(discovered.files)
assert (tmp_path / "keys/good.yaml").resolve() in resolved
assert (tmp_path / "keys/bad.yaml").resolve() in resolved
matching = [
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
]
assert matching == ["WARNING"]
def test_discover_user_yaml_files_tolerates_templated_top_level_include(
tmp_path: Path,
) -> None:
"""A literal include whose entire content is a templated ``!include`` is
tracked and skipped instead of aborting discovery."""
_write(tmp_path, "wrapper.yaml", "!include ${x}_settings.yaml\n")
discovered = discover_user_yaml_files(
_write_entry_including(tmp_path, "wrapper.yaml")
)
assert (tmp_path / "wrapper.yaml").resolve() in discovered.files
def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
"""`track_yaml_loads` is the building block — sanity-check it resolves
symlinks so callers can dedupe by identity."""
@@ -1491,3 +1780,122 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None:
assert result["api"] == {"reboot_timeout": "5min"}
assert result["logger"] == {"level": "DEBUG"}
assert yaml_util.take_dropped_merge_keys() == []
# ---------------------------------------------------------------------------
# track_document_range=False (validated-config-cache fast path)
# ---------------------------------------------------------------------------
FAST_MODE_MAIN_YAML = """\
defaults: &defaults
port: 6053
reboot_timeout: 15min
esphome:
name: !secret devname
api:
<<: *defaults
port: 6054
number_value: 42
float_value: 3.5
lambda_value: !lambda 'return x * 2;'
extend_value: !extend some_id
remove_value: !remove some_id
literal_value: !literal keep_me_verbatim
included: !include included.yaml
"""
@pytest.fixture
def fast_mode_config_dir(tmp_path: Path) -> Path:
_write(tmp_path, "main.yaml", FAST_MODE_MAIN_YAML)
_write(tmp_path, "included.yaml", "inner_key: inner_value\ninner_num: 7\n")
_write(tmp_path, "secrets.yaml", "devname: livingroom\n")
return tmp_path
def _resolve_includes(config: dict) -> dict:
return {
key: value.load() if isinstance(value, yaml_util.IncludeFile) else value
for key, value in config.items()
}
def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None:
"""Both modes produce equal values; only the metadata wrapping differs."""
yaml_file = fast_mode_config_dir / "main.yaml"
normal = _resolve_includes(yaml_util.load_yaml(yaml_file))
fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False))
# Lambda has no __eq__; compare it by value and the rest structurally.
fast_lambda = fast.pop("lambda_value")
normal_lambda = normal.pop("lambda_value")
assert fast == normal
assert isinstance(fast_lambda, core.Lambda)
assert fast_lambda.value == normal_lambda.value == "return x * 2;"
assert fast["esphome"]["name"] == "livingroom"
assert fast["api"]["port"] == 6054
assert fast["api"]["reboot_timeout"] == "15min"
assert fast["extend_value"] == Extend("some_id")
assert fast["remove_value"] == Remove("some_id")
# !literal wraps via make_literal, independent of range tracking.
assert isinstance(fast["literal_value"], ESPLiteralValue)
assert fast["literal_value"] == "keep_me_verbatim"
# Fast mode returns plain values; default mode keeps the range metadata.
assert not isinstance(fast["number_value"], ESPHomeDataBase)
assert not isinstance(fast["float_value"], ESPHomeDataBase)
assert all(type(key) is str for key in fast)
assert isinstance(normal["number_value"], ESPHomeDataBase)
assert normal["number_value"].esp_range is not None
assert all(isinstance(key, ESPHomeDataBase) for key in normal)
# Nested includes inherit fast mode through the recursive loader.
included = fast["included"]
assert not isinstance(included["inner_num"], ESPHomeDataBase)
assert all(type(key) is str for key in included)
def test_load_yaml_fast_mode_survives_pure_python_fallback(
fast_mode_config_dir: Path,
) -> None:
"""The ESPHomePurePythonLoader retry must honour fast mode too."""
yaml_file = fast_mode_config_dir / "main.yaml"
class _AlwaysFailingLoader(yaml_util.ESPHomeLoader):
def __init__(self, *args, **kwargs) -> None:
raise EsphomeError("forced fallback to the pure-Python loader")
with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader):
fast = yaml_util.load_yaml(yaml_file, track_document_range=False)
assert not isinstance(fast["number_value"], ESPHomeDataBase)
assert all(type(key) is str for key in fast)
def test_load_yaml_fast_mode_rejects_custom_loader() -> None:
"""A caller-supplied yaml_loader cannot combine with fast mode."""
with pytest.raises(ValueError, match="default yaml_loader"):
yaml_util.parse_yaml(
Path("x.yaml"),
io.StringIO("a: 1"),
lambda f: {},
track_document_range=False,
)
def test_load_yaml_fast_mode_records_dropped_merge_keys(
fast_mode_config_dir: Path,
) -> None:
"""The duplicate-merge-key bookkeeping must not crash on plain str keys.
With plain keys there is no esp_range, so the recorded location falls
back to the parent file name.
"""
yaml_file = fast_mode_config_dir / "main.yaml"
yaml_util.load_yaml(yaml_file, track_document_range=False)
assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))]
+4 -1
View File
@@ -59,7 +59,10 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path):
assert "-DFOO" in out
assert "-Wall" in out
assert "zephyr_link_libraries(" in out
assert "-Llibdir" in out
# -L paths are absolutised against the library dir (the CMakeLists lives in a
# zephyr/ subdir, so a relative path would resolve from the wrong place).
abs_libdir = str((tmp_path / "libdir").resolve()).replace("\\", "\\\\")
assert f"-L{abs_libdir}" in out
assert "-lm" in out