mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:17:23 +00:00
123 lines
4.5 KiB
Python
Executable File
123 lines
4.5 KiB
Python
Executable File
#!/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())
|