[core] Add generic component alias infrastructure (#16826)

This commit is contained in:
Jesse Hills
2026-06-18 13:21:00 +12:00
committed by GitHub
parent e3f164fff2
commit c214a8ce79
3 changed files with 1068 additions and 2 deletions

View File

@@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool:
return path[: len(other)] == other return path[: len(other)] == other
# CORE.data key for the per-alias "already warned this run" dedupe set.
# Cleared between runs because CORE.data is reset; one warning per alias
# per `esphome config|compile|run` invocation is the desired UX.
_ALIAS_WARNED_KEY = "_component_aliases_warned"
def _resolve_component_aliases(config: dict[str, Any]) -> None:
"""Rewrite legacy top-level keys to their canonical names, in place.
Looks up each top-level key against the component-alias map built by
:mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a
matching alias is found, the key is moved to its canonical name and a
one-shot deprecation warning is logged (per alias, per run — deduped
via ``CORE.data``).
Ambiguous configurations raise ``cv.Invalid`` rather than silently
keeping one entry — that would hide a real misconfiguration. Two cases
are rejected: the canonical key together with one of its deprecated
aliases, and two or more different aliases of the same canonical
component.
The rest of the validator chain (dependency resolution, schema
validation, codegen) sees only canonical names, so component
`DEPENDENCIES = ["<canonical>"]` works regardless of which spelling
the user typed.
"""
alias_meta_map = loader.get_alias_metadata()
if not alias_meta_map:
return
# Group every legacy alias key present in the config by the canonical
# component it resolves to, preserving config order within each group.
legacy_by_canonical: dict[str, list[str]] = {}
for key in config:
meta = alias_meta_map.get(key)
if meta is not None:
legacy_by_canonical.setdefault(meta.canonical, []).append(key)
if not legacy_by_canonical:
return
# Reject ambiguous configurations up front — checking before rewriting
# means a conflict is caught regardless of key order.
for canonical, legacies in legacy_by_canonical.items():
if canonical in config:
# The canonical key and (at least) one deprecated alias are both
# present.
raise vol.Invalid(
f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') "
f"and '{canonical}:' are present in the configuration. Remove "
f"the deprecated '{legacies[0]}:' key.",
path=[legacies[0]],
)
if len(legacies) > 1:
# Several different deprecated aliases of the same component.
listed = ", ".join(f"'{alias}:'" for alias in legacies)
raise vol.Invalid(
f"Multiple deprecated aliases of '{canonical}:' are present "
f"({listed}). Use only '{canonical}:'.",
path=[legacies[0]],
)
warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set())
# Rebuild in place so each canonical key keeps the legacy key's original
# position — top-level key order matters for some downstream passes
# (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)`
# would instead move the renamed key to the end.
rewritten: dict[str, Any] = {}
for key, value in config.items():
meta = alias_meta_map.get(key)
if meta is None:
rewritten[key] = value
continue
rewritten[meta.canonical] = value
if key not in warned:
warned.add(key)
removal = (
f" Removed in {meta.removal_version}." if meta.removal_version else ""
)
_LOGGER.warning(
"The '%s:' top-level key is deprecated; rename it to '%s:'.%s",
key,
meta.canonical,
removal,
)
config.clear()
config.update(rewritten)
@functools.total_ordering @functools.total_ordering
class _ValidationStepTask: class _ValidationStepTask:
def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): def __init__(self, priority: float, id_number: int, step: ConfigValidationStep):
@@ -1048,6 +1138,18 @@ def validate_config(
substitutions = config.pop(CONF_SUBSTITUTIONS, None) substitutions = config.pop(CONF_SUBSTITUTIONS, None)
CORE.raw_config = config CORE.raw_config = config
# 1.15. Resolve component aliases so legacy top-level keys
# (`rp2040:`, …) route to their canonical component before any
# downstream pass touches the config. Logs a deprecation warning
# per alias; mutates `config` in place. Errors here surface as
# plain config errors and abort further validation.
try:
_resolve_component_aliases(config)
except vol.Invalid as err:
result.update(config)
result.add_error(err)
return result
# 1.2. Resolve !extend and !remove and check for REPLACEME # 1.2. Resolve !extend and !remove and check for REPLACEME
# After this step, there will not be any Extend or Remove values in the config anymore # After this step, there will not be any Extend or Remove values in the config anymore
try: try:

View File

@@ -101,6 +101,27 @@ class ComponentManifest:
def codeowners(self) -> list[str]: def codeowners(self) -> list[str]:
return getattr(self.module, "CODEOWNERS", []) return getattr(self.module, "CODEOWNERS", [])
@property
def aliases(self) -> list[str]:
"""Legacy names that should transparently route to this component.
See the :func:`_build_alias_map` documentation for how aliases are
discovered (AST scan, no execution) and registered both for the YAML
loader (top-level key rename in :mod:`esphome.config`) and for
Python imports (``sys.meta_path`` finder, below).
"""
return getattr(self.module, "ALIASES", [])
@property
def alias_removal_version(self) -> str | None:
"""Optional ESPHome version when the alias warning becomes a hard error.
Surfaced in the deprecation warning emitted by the YAML pre-pass so
users know how long they have to migrate. ``None`` means the warning
does not mention a specific version.
"""
return getattr(self.module, "ALIAS_REMOVAL_VERSION", None)
@property @property
def instance_type(self) -> "MockObjClass | None": def instance_type(self) -> "MockObjClass | None":
return getattr(self.module, "INSTANCE_TYPE", None) return getattr(self.module, "INSTANCE_TYPE", None)
@@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
_COMPONENT_CACHE[domain] = manif _COMPONENT_CACHE[domain] = manif
return manif return manif
# 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)
if manif is not None:
_COMPONENT_CACHE[domain] = manif
return manif
try: try:
module = importlib.import_module(f"esphome.components.{domain}") module = importlib.import_module(f"esphome.components.{domain}")
except ImportError as e: except ImportError as e:
@@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
code should never call this. code should never call this.
""" """
_COMPONENT_CACHE[domain] = manifest _COMPONENT_CACHE[domain] = manifest
# ---------------------------------------------------------------------------
# Component aliases (renamed-platform back-compat)
# ---------------------------------------------------------------------------
#
# 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:
#
# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``)
# intercepts ``esphome.components.<legacy>``/``...<legacy>.<sub>``
# imports and resolves them against the canonical component so external
# custom components that still import from the old path keep working.
#
# 2. **YAML loader** — ``_lookup_module`` consults the alias map so
# ``get_component("legacy")`` returns the canonical manifest. The
# ``esphome.config`` pre-pass uses the same map to rewrite legacy
# top-level keys in the user's config (with a deprecation warning) so
# 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.
_ALIAS_MAP_CACHE: dict[str, str] | None = None
_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None
@dataclass(frozen=True)
class AliasMeta:
"""Metadata for a single deprecated alias entry.
Used by the YAML pre-pass in :mod:`esphome.config` to produce a
deprecation warning citing the canonical name and (optionally) the
removal version declared by the canonical component.
"""
canonical: str
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).
Used by the YAML pre-pass to format a per-alias deprecation warning.
"""
_ensure_alias_caches()
return _ALIAS_META_CACHE
def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]:
"""Scan every core component dir for ``ALIASES`` declarations.
Uses :mod:`ast` to read each component's ``__init__.py`` without
executing it — component import side-effects (logger setup,
namespace registration, etc.) shouldn't run just because we're
enumerating aliases.
Raises if the same alias is claimed by two canonical components, since
silently picking one would cause non-deterministic routing depending on
directory-iteration order. Also raises if an alias shadows an existing
component package: that would hijack a live component domain and, in the
self-alias case (alias == canonical), send ``_lookup_module`` into
infinite recursion redirecting a domain to itself.
"""
import ast
alias_to_canonical: dict[str, str] = {}
alias_to_meta: dict[str, AliasMeta] = {}
if not CORE_COMPONENTS_PATH.is_dir():
return alias_to_canonical, alias_to_meta
for child in sorted(CORE_COMPONENTS_PATH.iterdir()):
if not child.is_dir():
continue
init = child / "__init__.py"
if not init.is_file():
continue
aliases, removal_version = _read_aliases(init, ast)
if not aliases:
continue
canonical = child.name
for alias in aliases:
if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file():
from esphome.core import EsphomeError
raise EsphomeError(
f"Component alias '{alias}' (declared by '{canonical}') "
"shadows an existing component package of the same name. "
"An alias may only name a component that no longer exists."
)
if alias in alias_to_canonical:
from esphome.core import EsphomeError
raise EsphomeError(
f"Component alias '{alias}' is declared by both "
f"'{alias_to_canonical[alias]}' and '{canonical}'. "
"Each alias must map to exactly one canonical component."
)
alias_to_canonical[alias] = canonical
alias_to_meta[alias] = AliasMeta(
canonical=canonical, removal_version=removal_version
)
return alias_to_canonical, alias_to_meta
def _read_aliases(
init_path: Path, ast_module: ModuleType
) -> tuple[list[str], str | None]:
"""Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component
``__init__.py`` via AST parsing.
Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."``
forms — anything more dynamic (function call, conditional, etc.) is
silently ignored. Components should keep their alias declarations
static so this scanner can see them.
"""
try:
source = init_path.read_text(encoding="utf-8")
except OSError as err:
_LOGGER.warning(
"Could not read %s while scanning for component aliases: %s",
init_path,
err,
)
return [], None
# Cheap substring pre-filter: almost no component declares ALIASES, and
# parsing every component __init__.py with ast is comparatively expensive.
# Skip the parse entirely unless the token appears in the file at all.
if "ALIASES" not in source:
return [], None
try:
tree = ast_module.parse(source)
except SyntaxError as err:
_LOGGER.warning(
"Could not parse %s while scanning for component aliases: %s",
init_path,
err,
)
return [], None
aliases: list[str] = []
removal_version: str | None = None
for node in tree.body:
if not isinstance(node, ast_module.Assign):
continue
for target in node.targets:
if not isinstance(target, ast_module.Name):
continue
if target.id == "ALIASES" and isinstance(node.value, ast_module.List):
aliases.extend(
elt.value
for elt in node.value.elts
if isinstance(elt, ast_module.Constant)
and isinstance(elt.value, str)
)
elif (
target.id == "ALIAS_REMOVAL_VERSION"
and isinstance(node.value, ast_module.Constant)
and isinstance(node.value.value, str)
):
removal_version = node.value.value
return aliases, removal_version
class _AliasFinder(importlib.abc.MetaPathFinder):
"""``sys.meta_path`` finder that resolves legacy-component imports.
Routes ``esphome.components.<alias>[.<submod>]`` to the canonical
component's module/submodule of the same name, so external code that
still imports ``from esphome.components.rp2040 import boards`` keeps
working without the canonical component having to maintain a shim
package on disk.
The finder caches the resolved module in ``sys.modules`` under the
legacy name on first lookup, so subsequent imports hit the cache and
skip this finder entirely.
"""
_PREFIX = "esphome.components."
def find_spec(self, fullname, path, target=None): # noqa: ARG002
if not fullname.startswith(self._PREFIX):
return None
# Anything matching the ``esphome.components.`` prefix splits into at
# 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:
return None
parts[2] = alias_map[domain]
canonical_fullname = ".".join(parts)
try:
canonical_module = importlib.import_module(canonical_fullname)
except ModuleNotFoundError as err:
# Only treat a missing *canonical target* as "no alias to
# resolve" (let the normal import machinery report it). If some
# other module is missing, the canonical exists but failed to
# import one of its own dependencies — surface that real error
# rather than masking it as an unresolved alias.
if err.name == canonical_fullname:
return None
raise
# Do NOT pre-populate ``sys.modules[fullname]`` here. Python's
# ``_find_spec`` (in importlib._bootstrap) has an optimization that
# detects ``name in sys.modules`` after a finder returns and prefers
# ``sys.modules[name].__spec__`` over the finder's spec — for an
# alias, that's the canonical module's own SourceFileLoader spec,
# which Python then *re-loads*, defeating the aliasing. Letting
# ``_load_unlocked`` populate sys.modules itself (via our
# ``_AliasLoader.create_module``) sidesteps that branch.
return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module))
class _AliasLoader(importlib.abc.Loader):
"""No-op loader that returns the already-resolved canonical module.
:class:`_AliasFinder` populates ``sys.modules`` itself; this loader
just satisfies the :mod:`importlib` protocol so Python doesn't try to
re-execute the module.
"""
def __init__(self, module: ModuleType) -> None:
self._module = module
def create_module(self, spec): # noqa: ARG002
return self._module
def exec_module(self, module): # noqa: ARG002
# Nothing to execute — the canonical module is already initialized.
return None
# Register once at module load. Idempotent: re-installing the finder on
# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op
# because we check for an existing instance first.
def _install_alias_finder() -> None:
for entry in sys.meta_path:
if isinstance(entry, _AliasFinder):
return
sys.meta_path.append(_AliasFinder())
_install_alias_finder()

View File

@@ -1,8 +1,28 @@
"""Unit tests for esphome.loader module.""" """Unit tests for esphome.loader module."""
from unittest.mock import MagicMock, patch import ast
import logging
from pathlib import Path
import sys
import textwrap
from types import ModuleType
from unittest.mock import MagicMock, Mock, patch
from esphome.loader import ComponentManifest, _replace_component_manifest, get_component import pytest
import voluptuous as vol
from esphome import config as esphome_config, config_validation as cv
from esphome.core import CORE
import esphome.loader as loader_mod
from esphome.loader import (
AliasMeta,
ComponentManifest,
_AliasFinder,
_build_alias_map,
_read_aliases,
_replace_component_manifest,
get_component,
)
from tests.testing_helpers import ComponentManifestOverride from tests.testing_helpers import ComponentManifestOverride
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub
names = [r.resource for r in manifest.resources] names = [r.resource for r in manifest.resources]
assert names == ["wake/wake_freertos.cpp"] assert names == ["wake/wake_freertos.cpp"]
# ---------------------------------------------------------------------------
# Component aliases (renamed-platform back-compat)
# ---------------------------------------------------------------------------
#
# These tests pin down the substrate behind `ALIASES = [...]` on component
# `__init__.py` files: the AST scanner, the resulting global alias map, the
# Python-import `sys.meta_path` finder, the `get_component` integration, and
# the YAML pre-pass that rewrites legacy top-level keys.
#
# The framework is component-agnostic, so the integration tests inject a
# synthetic alias map (pointing a fake legacy name at the real `esp32`
# component) rather than depending on any specific renamed component.
# A legacy name that is NOT a real component, used as a synthetic alias.
_FAKE_ALIAS = "esp32_legacy_alias"
def _write_component(root: Path, name: str, body: str) -> None:
"""Write a fake component package at ``root/<name>/__init__.py``."""
pkg = root / name
pkg.mkdir()
(pkg / "__init__.py").write_text(body)
def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None:
"""AST scan should pick up ``ALIASES = ["legacy"]`` without executing."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['legacy_name']\n")
aliases, removal = _read_aliases(init, ast)
assert aliases == ["legacy_name"]
assert removal is None
def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None:
"""``ALIAS_REMOVAL_VERSION`` should be paired with the alias list."""
init = tmp_path / "__init__.py"
init.write_text(
textwrap.dedent("""\
ALIASES = ['old']
ALIAS_REMOVAL_VERSION = "2027.6.0"
""")
)
aliases, removal = _read_aliases(init, ast)
assert aliases == ["old"]
assert removal == "2027.6.0"
def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None:
"""A call-expression / non-literal ALIASES shouldn't surface — the
scanner deliberately ignores anything non-static to keep behavior
predictable (and avoid executing component code)."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n")
aliases, _ = _read_aliases(init, ast)
assert aliases == []
def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None:
init = tmp_path / "__init__.py"
init.write_text("CODEOWNERS = ['@me']\n")
aliases, removal = _read_aliases(init, ast)
assert aliases == []
assert removal is None
def test_read_aliases_handles_syntax_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A broken __init__.py shouldn't crash the alias scanner — it'll
surface as an ImportError elsewhere, but the scanner logs a warning and
yields nothing so other components keep working. The substring pre-filter
only skips files with no ``ALIASES`` token, so this file (which has one)
still reaches the parse."""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['x']\ndef broken( :\n")
assert _read_aliases(init, ast) == ([], None)
assert "Could not parse" in caplog.text
def test_read_aliases_handles_read_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable __init__.py logs a warning and yields nothing rather
than aborting the whole component scan."""
missing = tmp_path / "nope" / "__init__.py"
assert _read_aliases(missing, ast) == ([], None)
assert "Could not read" in caplog.text
def test_build_alias_map_aggregates_components(tmp_path: Path) -> None:
"""End-to-end map build over a fake components dir."""
_write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n")
_write_component(tmp_path, "other", "")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, meta_map = _build_alias_map()
assert alias_map == {"oldcomp": "newcomp"}
assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}
def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None:
_write_component(
tmp_path,
"newcomp",
"ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n",
)
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
_, meta_map = _build_alias_map()
assert meta_map["oldcomp"].removal_version == "2028.1.0"
def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None:
"""If two canonical components both claim the same legacy alias,
routing becomes ambiguous — the build must refuse to start so the
conflict surfaces immediately at import time, not later as a
'mysterious wrong component' bug."""
_write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n")
_write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shared"),
):
_build_alias_map()
def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None:
"""If the components directory doesn't exist (unlikely in production,
but possible in some test contexts), we want an empty map rather than
a crash — the rest of the loader can still function."""
fake = tmp_path / "does-not-exist"
with patch("esphome.loader.CORE_COMPONENTS_PATH", fake):
alias_map, meta_map = _build_alias_map()
assert alias_map == {}
assert meta_map == {}
def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None:
"""An alias that names an existing component package is refused: it would
hijack a live domain, and a self-alias (alias == canonical) would send
``_lookup_module`` into infinite recursion."""
# `newcomp` declares itself as an alias — its own package already exists.
_write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shadows an existing component"),
):
_build_alias_map()
# ---- Integration against a synthetic alias map (fake legacy -> esp32) ----
def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None:
"""Force the loader's alias map (used by the finder and get_component).
Patches the lazily-built caches so both ``_get_alias_map`` and the
installed meta-path finder resolve against ``mapping`` regardless of
what the real on-disk scan would produce.
"""
monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping)
def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None:
"""``get_component(<alias>)`` should return the canonical manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
the canonical component without knowing about the alias."""
import esphome.loader as loader_mod
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None)
canonical = get_component("esp32")
aliased = get_component(_FAKE_ALIAS)
assert canonical is not None
assert aliased is canonical
def test_alias_finder_resolves_top_level_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``import esphome.components.<alias>`` resolves to the canonical
module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None)
assert spec is not None
import esphome.components.esp32
import esphome.components.esp32_legacy_alias
assert esphome.components.esp32_legacy_alias is esphome.components.esp32
def test_alias_finder_resolves_submodule_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``from esphome.components.<alias> import boards`` routes through to
``esphome.components.esp32.boards`` — same submodule object on both paths.
The canonical submodule is imported first so its parent module carries
the ``boards`` attribute; ``from <alias> import boards`` then resolves
the aliased parent (via the finder) and reads that same attribute,
rather than triggering a fresh file load under the alias name.
``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None)
assert spec is not None
from esphome.components.esp32 import boards as canonical_boards
from esphome.components.esp32_legacy_alias import boards as aliased_boards
assert aliased_boards is canonical_boards
def test_alias_finder_ignores_non_components_path() -> None:
"""The finder must scope itself to ``esphome.components.<X>`` —
everything else (other esphome submodules, third-party packages) is
left for the normal import machinery."""
finder = _AliasFinder()
assert finder.find_spec("esphome.core", None) is None
assert finder.find_spec("os.path", None) is None
# `esphome.components` itself (no domain segment) is not a candidate.
assert finder.find_spec("esphome.components", None) is None
# A real, non-aliased component domain defers to normal import machinery
# (no component declares an alias in this repo, so the live map is empty).
assert finder.find_spec("esphome.components.logger", None) is None
# ---------------------------------------------------------------------------
# YAML pre-pass: top-level key rename + centralized deprecation warning
# ---------------------------------------------------------------------------
#
# The companion to the loader-side alias map: ``esphome.config`` runs a
# pre-pass over the user's parsed YAML that rewrites legacy top-level keys
# to their canonical names, surfacing a one-shot deprecation warning. These
# tests inject a synthetic alias-metadata map so the rewrite behavior, the
# warning text, and the both-keys-present conflict can be tested in isolation.
def _patch_alias_metadata(
monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta]
) -> None:
monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping)
def test_resolve_component_aliases_renames_legacy_key(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A legacy alias key should be renamed to the canonical key and a
deprecation warning citing the removal version logged."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires
config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}}
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert "oldcomp" not in config
assert config["newcomp"] == {"board": "x"}
assert any(
"'oldcomp:' top-level key is deprecated" in record.message
and "rename it to 'newcomp:'" in record.message
and "2027.6.0" in record.message
for record in caplog.records
)
def test_resolve_component_aliases_dedupes_warning_within_a_run(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Schema validators can run twice (auto-load discovery + final pass)
so the rename pass must emit the warning only once per alias per run.
Deduped via ``CORE.data``; cleared between runs."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases({"oldcomp": {"board": "a"}})
_resolve_component_aliases({"oldcomp": {"board": "b"}})
matches = [
r
for r in caplog.records
if "'oldcomp:' top-level key is deprecated" in r.message
]
assert len(matches) == 1
def test_resolve_component_aliases_rejects_both_keys_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the user has BOTH legacy and canonical keys, silently dropping
one would hide a real misconfiguration. Raise instead."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_canonical_key_after_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The both-keys conflict must be detected even when the canonical key
appears *after* the legacy key in the config (the up-front conflict
scan, not a position-dependent check)."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two different deprecated aliases of the same canonical component is
ambiguous — silently keeping one would hide a misconfiguration."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{
"oldcomp": AliasMeta(canonical="newcomp", removal_version=None),
"legacycomp": AliasMeta(canonical="newcomp", removal_version=None),
},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}}
with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_preserves_key_position(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The renamed canonical key keeps the legacy key's original position
rather than being moved to the end of the config."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}}
_resolve_component_aliases(config)
assert list(config) == ["esphome", "newcomp", "logger"]
def test_resolve_component_aliases_no_op_when_no_legacy_keys(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The pre-pass must be a no-op (no warning, no mutation) for configs
that already use canonical keys."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}}
original = dict(config)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert config == original
assert not any("deprecated" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# ComponentManifest alias properties
# ---------------------------------------------------------------------------
def test_component_manifest_alias_properties_default_empty() -> None:
"""``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None``
when the component module declares neither.
Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the
``getattr(..., default)`` fallback is actually exercised — a bare mock
auto-creates any attribute on access and would never hit the default."""
mod = ModuleType("fake_component")
manifest = ComponentManifest(mod)
assert manifest.aliases == []
assert manifest.alias_removal_version is None
def test_component_manifest_alias_properties_read_module_values() -> None:
"""The properties surface the module's declared values verbatim."""
mod = MagicMock()
mod.ALIASES = ["legacy"]
mod.ALIAS_REMOVAL_VERSION = "2027.6.0"
manifest = ComponentManifest(mod)
assert manifest.aliases == ["legacy"]
assert manifest.alias_removal_version == "2027.6.0"
# ---------------------------------------------------------------------------
# Real (unpatched) lazy build + cache and remaining scanner branches
# ---------------------------------------------------------------------------
def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise the real lazy build over the actual components dir (no patch):
the first call scans and caches, the second returns the cached object."""
monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None)
first = loader_mod._get_alias_map()
second = loader_mod._get_alias_map()
assert isinstance(first, dict)
assert first is second # cached, not rebuilt on the second call
def test_get_alias_metadata_real_build_and_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None)
first = loader_mod.get_alias_metadata()
second = loader_mod.get_alias_metadata()
assert isinstance(first, dict)
assert first is second
def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None:
"""Loose files and directories without an ``__init__.py`` are ignored;
only real component packages contribute to the map."""
(tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n")
(tmp_path / "initless").mkdir() # a dir, but no __init__.py
_write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, _ = _build_alias_map()
assert alias_map == {"legacy": "realcomp"}
def test_read_aliases_ignores_non_assignment_and_complex_targets(
tmp_path: Path,
) -> None:
"""Non-assignment statements and assignments to non-Name targets are
skipped; only simple ``NAME = ...`` assignments are read."""
init = tmp_path / "__init__.py"
init.write_text(
"import os\n" # non-Assign (Import) node -> skipped
"obj.attr = 'v'\n" # Assign with an Attribute target -> skipped
"ALIASES = ['legacy']\n"
)
aliases, _ = _read_aliases(init, ast)
assert aliases == ["legacy"]
# ---------------------------------------------------------------------------
# Finder / loader edge branches
# ---------------------------------------------------------------------------
def test_alias_finder_returns_none_when_canonical_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias points at a canonical *target* that doesn't exist, the
finder declines (returns None) and lets normal import machinery report
the missing module."""
_patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"})
finder = _AliasFinder()
assert finder.find_spec("esphome.components.broken_alias", None) is None
def test_alias_finder_reraises_when_canonical_dependency_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the canonical module exists but fails to import one of its own
dependencies, the finder surfaces that real error instead of masking it
as an unresolved alias (which would silently fall through to a confusing
'no module named <alias>')."""
_patch_alias_map(monkeypatch, {"some_alias": "real_canonical"})
def boom(name: str) -> None:
raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep")
monkeypatch.setattr("esphome.loader.importlib.import_module", boom)
finder = _AliasFinder()
with pytest.raises(ModuleNotFoundError, match="missing_dep"):
finder.find_spec("esphome.components.some_alias", None)
def test_install_alias_finder_is_idempotent() -> None:
"""The finder is installed once at import; calling the installer again is
a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``)."""
before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(before) == 1 # installed at module import time
loader_mod._install_alias_finder()
after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(after) == 1
def test_get_component_alias_to_missing_canonical_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias resolves to a canonical component that can't be loaded,
``get_component`` returns None and caches no bogus manifest."""
_patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"})
loader_mod._COMPONENT_CACHE.pop("ghost_alias", None)
assert get_component("ghost_alias") is None
assert "ghost_alias" not in loader_mod._COMPONENT_CACHE
# ---------------------------------------------------------------------------
# YAML pre-pass: empty-map fast path + validate_config integration
# ---------------------------------------------------------------------------
def test_resolve_component_aliases_noop_when_no_aliases_declared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no component declares an alias, the pre-pass returns immediately
without inspecting or mutating the config."""
from esphome.config import _resolve_component_aliases
monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map
config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}}
original = dict(config)
_resolve_component_aliases(config)
assert config == original
def _default_component_mock() -> Mock:
"""A permissive component mock that validates any config (ALLOW_EXTRA)."""
return Mock(
auto_load=[],
is_platform_component=False,
is_platform=False,
multi_conf=False,
multi_conf_no_default=False,
dependencies=[],
conflicts_with=[],
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
)
@pytest.mark.usefixtures("setup_core")
def test_validate_config_renames_alias_key(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end: a legacy top-level key is renamed to its canonical name
before the rest of ``validate_config`` runs, and validation succeeds.
A real ``esp32`` target platform is included so ``preload_core_config``
is satisfied and validation runs to completion (the renamed canonical
key is loaded via the mocked, permissive component)."""
mock_get_component.side_effect = lambda name: _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {
"legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0")
},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"esp32": {"board": "esp32dev"},
"legacyfoo": {"opt": 1},
}
result = esphome_config.validate_config(raw_config, {})
assert not result.errors, f"unexpected errors: {result.errors}"
assert "newcomp" in result
assert "legacyfoo" not in result
@pytest.mark.usefixtures("setup_core")
def test_validate_config_reports_alias_conflict_as_error(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If both the legacy and canonical keys are present, ``validate_config``
surfaces the conflict as a config error (the ``vol.Invalid`` path)."""
mock_get_component.return_value = _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"newcomp": {"opt": 1},
"legacyfoo": {"opt": 2},
}
result = esphome_config.validate_config(raw_config, {})
assert result.errors
assert "Both 'legacyfoo:'" in str(result.errors)