mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 02:47:52 +08:00
[core] Load component aliases from a generated registry (#18335)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Executable
+59
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user