Revert "[tests] Fail component test merge on conflicting duplicate IDs" (#16848)

This commit is contained in:
J. Nick Koston
2026-06-05 19:37:19 -05:00
committed by GitHub
parent 8f8a70b2be
commit 2a4913713a
115 changed files with 252 additions and 482 deletions
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""Fail when two component test fixtures define the same id with different content.
Component tests are merged and built in groups in CI (see
``script/merge_component_configs.py``). When two components declare the same id
under the same section but with different content, the merge silently keeps the
first and drops the rest, which can make a cross-reference resolve to an
incompatible entity (this is what broke the i2s_audio speaker tests). The merge
now raises on such a collision, but only when the two components land in the same
group. This script is the complete, batch-independent guard: it scans every
component's ``test.<platform>.yaml`` per platform and reports any id that is
defined by more than one component with differing content.
Ids that are intentionally shared across components (e.g. a singleton
``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped.
"""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from script.merge_component_configs import ( # noqa: E402
INTENTIONALLY_SHARED_IDS,
load_yaml_file,
)
TESTS_DIR = Path("tests/components")
def _normalize(value: object) -> object:
"""Return a hashable, order-independent representation for comparison."""
if isinstance(value, dict):
return tuple(sorted((str(k), _normalize(v)) for k, v in value.items()))
if isinstance(value, (list, tuple)):
return tuple(_normalize(v) for v in value)
# Scalars (and ESPHome tag objects like !lambda) compare by their text form
return str(value)
def _collect_ids(
data: object, section: str, out: dict[tuple[str, str], object]
) -> None:
"""Walk a parsed config and record (section, id) -> normalized content."""
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, list):
for item in value:
if isinstance(item, dict) and "id" in item:
out[(key, str(item["id"]))] = _normalize(item)
_collect_ids(item, key, out)
else:
_collect_ids(value, key, out)
elif isinstance(data, list):
for item in data:
_collect_ids(item, section, out)
def _discover_platforms() -> set[str]:
platforms: set[str] = set()
for test_file in TESTS_DIR.glob("*/test.*.yaml"):
# test.<platform>.yaml -> platform is the middle dotted part
parts = test_file.name.split(".")
if len(parts) == 3:
platforms.add(parts[1])
return platforms
def main() -> int:
conflicts: list[str] = []
for platform in sorted(_discover_platforms()):
# (section, id) -> {normalized_content: [components]}
by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict(
lambda: defaultdict(list)
)
for comp_dir in sorted(TESTS_DIR.iterdir()):
if not comp_dir.is_dir():
continue
test_file = comp_dir / f"test.{platform}.yaml"
if not test_file.exists():
continue
try:
data = load_yaml_file(test_file)
except Exception as err: # noqa: BLE001
print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr)
continue
ids: dict[tuple[str, str], object] = {}
_collect_ids(data, "", ids)
for (section, id_), content in ids.items():
if id_ in INTENTIONALLY_SHARED_IDS:
continue
by_id[(section, id_)][content].append(comp_dir.name)
for (section, id_), variants in sorted(by_id.items()):
if len(variants) < 2:
continue
components = sorted({c for comps in variants.values() for c in comps})
conflicts.append(
f"[{platform}] id '{id_}' under '{section}' is defined "
f"differently by: {', '.join(components)}"
)
if conflicts:
print("Conflicting test component ids found:\n")
for line in conflicts:
print(f" - {line}")
print(
"\nGive each component a unique id (e.g. '<component>_<id>'), or add the "
"id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if "
"it is a deliberately shared singleton."
)
return 1
print("No conflicting test component ids found.")
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -41
View File
@@ -161,42 +161,18 @@ def prefix_substitutions_in_dict(
return data
# Ids that several components intentionally share. ESPHome treats these as a
# single instance when merged (e.g. multiple components each declaring the same
# `sntp_time` clock collapse into one), so duplicates with differing content are
# expected and must not be flagged as accidental collisions.
INTENTIONALLY_SHARED_IDS = frozenset(
{
# Several components each declare an `sntp_time` clock; ESPHome merges
# them into one time source.
"sntp_time",
# esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the
# ESP32-P4; only one LDO per channel may exist, so the shared id lets the
# merge collapse them into a single LDO.
"ldo_id",
}
)
def deduplicate_by_id(data: dict) -> dict:
"""Deduplicate list items with the same ID.
Identical items sharing an ID (e.g. a shared bus from a common package pulled
in by several components) are collapsed to the first occurrence. Two items that
share an ID but differ in content are a real conflict: when merged, the first
one silently wins and the others are dropped, which can make cross-references
resolve to an incompatible entity. Rather than defer that to downstream
validation (where it surfaces as a confusing, order-dependent failure), raise
immediately so the offending ID is named.
Keeps only the first occurrence of each ID. If items with the same ID
are identical, this silently deduplicates. If they differ, the first
one is kept (ESPHome's validation will catch if this causes issues).
Args:
data: Parsed config dictionary
Returns:
Config with deduplicated lists
Raises:
ValueError: If two items share an ID but have different content.
"""
if not isinstance(data, dict):
return data
@@ -205,26 +181,16 @@ def deduplicate_by_id(data: dict) -> dict:
for key, value in data.items():
if isinstance(value, list):
# Check for items with 'id' field
seen_items = {}
seen_ids = set()
deduped_list = []
for item in value:
if isinstance(item, dict) and "id" in item:
item_id = item["id"]
if item_id not in seen_items:
seen_items[item_id] = item
if item_id not in seen_ids:
seen_ids.add(item_id)
deduped_list.append(item)
elif item_id in INTENTIONALLY_SHARED_IDS:
# Designed singleton shared by several components (e.g. an
# `sntp_time` clock); ESPHome collapses these, so keep first.
pass
elif item != seen_items[item_id]:
raise ValueError(
f"Conflicting definitions for id '{item_id}' under "
f"'{key}' when merging test configs; give each "
f"component a unique id"
)
# else: identical duplicate (e.g. shared bus package) -> skip
# else: skip duplicate ID (keep first occurrence)
else:
# No ID, just add it
deduped_list.append(item)