mirror of
https://github.com/esphome/esphome.git
synced 2026-09-19 19:18:47 +00:00
Revert "[tests] Fail component test merge on conflicting duplicate IDs" (#16848)
This commit is contained in:
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user