[core] Load component aliases from a generated registry (#18335)

This commit is contained in:
J. Nick Koston
2026-08-14 17:36:54 +12:00
committed by Jesse Hills
parent 4f3153375a
commit 02c1810c3a
5 changed files with 122 additions and 37 deletions
+1
View File
@@ -179,6 +179,7 @@ jobs:
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_alias_registry.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
+10
View File
@@ -0,0 +1,10 @@
"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
"rp2040": ("rp2", "2027.7.0"),
}
+24 -37
View File
@@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
# If `domain` is the legacy name of a renamed component, redirect to the
# canonical module so the rest of the loader (and every caller of
# `get_component(legacy)`) transparently sees the new component.
alias_map = _get_alias_map()
if domain in alias_map:
canonical = alias_map[domain]
manif = _lookup_module(canonical, exception)
alias_meta = get_alias_metadata().get(domain)
if alias_meta is not None:
manif = _lookup_module(alias_meta.canonical, exception)
if manif is not None:
_COMPONENT_CACHE[domain] = manif
return manif
@@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# ---------------------------------------------------------------------------
#
# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two
# integrations are then wired up automatically:
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run
# ``script/build_alias_registry.py`` to regenerate
# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry
# is stale). Two integrations are then wired up automatically:
#
# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``)
# intercepts ``esphome.components.<legacy>``/``...<legacy>.<sub>``
@@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# dependency checks, schema validation and codegen all see only the
# canonical name.
#
# Both lookups are populated by ``_build_alias_map``, which **AST-parses**
# every component's ``__init__.py`` rather than importing it. That keeps the
# cost low: scanning ~400 components on disk takes ~5 ms instead of the
# multi-second cost of executing every component's import side-effects.
# Both lookups read the checked-in registry in ``esphome.component_aliases``
# (generated by ``script/build_alias_registry.py``, verified in CI), so no
# component-directory scan happens at runtime. ``_build_alias_map`` below is
# the generator's scan implementation; it **AST-parses** each component's
# ``__init__.py`` rather than importing it.
_ALIAS_MAP_CACHE: dict[str, str] | None = None
_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None
@@ -367,31 +368,17 @@ class AliasMeta:
removal_version: str | None
def _ensure_alias_caches() -> None:
"""Populate both alias caches from a single directory scan.
``_build_alias_map`` returns both maps together, so building them in one
shot avoids scanning every component's ``__init__.py`` twice when a run
needs both the canonical map (loader) and the metadata map (config
pre-pass).
"""
global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE
if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None:
_ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map()
def _get_alias_map() -> dict[str, str]:
"""Return the legacy-name → canonical-name map, building it lazily."""
_ensure_alias_caches()
return _ALIAS_MAP_CACHE
def get_alias_metadata() -> dict[str, AliasMeta]:
"""Return the legacy-name → :class:`AliasMeta` map (cached).
"""Return the legacy-name → :class:`AliasMeta` map, built lazily from
the generated registry."""
global _ALIAS_META_CACHE # noqa: PLW0603
if _ALIAS_META_CACHE is None:
from esphome.component_aliases import COMPONENT_ALIASES
Used by the YAML pre-pass to format a per-alias deprecation warning.
"""
_ensure_alias_caches()
_ALIAS_META_CACHE = {
alias: AliasMeta(canonical=canonical, removal_version=removal_version)
for alias, (canonical, removal_version) in COMPONENT_ALIASES.items()
}
return _ALIAS_META_CACHE
@@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder):
# least three parts, so ``parts[2]`` (the domain) always exists.
parts = fullname.split(".")
domain = parts[2]
alias_map = _get_alias_map()
if domain not in alias_map:
alias_meta = get_alias_metadata().get(domain)
if alias_meta is None:
return None
parts[2] = alias_map[domain]
parts[2] = alias_meta.canonical
canonical_fullname = ".".join(parts)
try:
canonical_module = importlib.import_module(canonical_fullname)
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Generate esphome/component_aliases.py from component ALIASES declarations.
Run without arguments to regenerate the registry; ``--check`` (run in CI)
verifies it is up to date.
"""
import argparse
from pathlib import Path
import sys
# The root directory of the repo
root = Path(__file__).parent.parent
# Make the repo's esphome package win over any installed copy
sys.path.insert(0, str(root))
from esphome.helpers import write_file_if_changed # noqa: E402
from esphome.loader import _build_alias_map # noqa: E402
parser = argparse.ArgumentParser()
parser.add_argument(
"--check",
help="Check if the alias registry is up to date.",
action="store_true",
)
args = parser.parse_args()
registry_file = root / "esphome" / "component_aliases.py"
HEADER = '''"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
'''
# _build_alias_map scans the real component tree and already rejects
# duplicate and shadowing aliases with an EsphomeError.
_, alias_meta = _build_alias_map()
lines = [HEADER]
for alias, meta in sorted(alias_meta.items()):
removal = f'"{meta.removal_version}"' if meta.removal_version else "None"
lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n')
lines.append("}\n")
content = "".join(lines)
if args.check:
if registry_file.read_text(encoding="utf-8") != content:
print("Component alias registry is not up to date.")
print("Please run `script/build_alias_registry.py`")
sys.exit(1)
print("Component alias registry is up to date")
else:
write_file_if_changed(registry_file, content)
print(f"Wrote {registry_file}")
+28
View File
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.component_aliases import COMPONENT_ALIASES
from esphome.loader import (
AliasMeta,
ComponentManifest,
@@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None:
assert meta["rp2040"].removal_version == "2027.7.0"
def test_alias_registry_matches_component_tree() -> None:
"""The checked-in registry must match a live scan of the component tree."""
_, meta_map = _build_alias_map()
expected = {
alias: (meta.canonical, meta.removal_version)
for alias, meta in meta_map.items()
}
assert expected == COMPONENT_ALIASES, (
"esphome/component_aliases.py is out of date; "
"run script/build_alias_registry.py"
)
def test_alias_map_built_from_registry() -> None:
"""The runtime alias map comes from the generated registry, not a scan."""
with (
patch(
"esphome.component_aliases.COMPONENT_ALIASES",
{"legacy": ("modern", "2099.1.0")},
),
patch("esphome.loader._ALIAS_META_CACHE", None),
):
assert get_alias_metadata() == {
"legacy": AliasMeta(canonical="modern", removal_version="2099.1.0")
}
def test_get_component_resolves_alias() -> None:
"""``get_component('rp2040')`` should return the rp2 manifest — every
caller of the loader (dep checker, schema validator, codegen) hits