mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 02:05:59 +00:00
Merge branch 'dev' into jesserockz-2026-503
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
+1236
-15
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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""
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user