mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:17:23 +00:00
[core] Add generic component alias infrastructure (#16826)
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user