Files
esphome-lsm6ds/esphome/components/packages/__init__.py
T

535 lines
19 KiB
Python

from collections import UserDict
from collections.abc import Callable
from functools import reduce
import logging
from pathlib import Path
from typing import Any
from esphome import git, yaml_util
from esphome.components.substitutions import (
ContextVars,
push_context,
resolve_include,
substitute,
)
from esphome.components.substitutions.jinja import has_jinja
from esphome.config_helpers import Remove, merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
CONF_FILE,
CONF_FILES,
CONF_MIN_VERSION,
CONF_PACKAGES,
CONF_PASSWORD,
CONF_PATH,
CONF_REF,
CONF_REFRESH,
CONF_SUBSTITUTIONS,
CONF_URL,
CONF_USERNAME,
CONF_VARS,
__version__ as ESPHOME_VERSION,
)
from esphome.core import EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = CONF_PACKAGES
# Guard against infinite include chains (e.g. A includes B includes A).
MAX_INCLUDE_DEPTH = 20
def is_remote_package(package_config: dict) -> bool:
"""Returns True if the package_config is a remote package definition."""
return CONF_URL in package_config
def valid_package_contents(package_config: dict) -> dict:
"""Validate that a package looks like a plausible ESPHome config fragment.
Rejects non-dict values, remote package schemas (which should have been
handled earlier), non-string keys, and scalar values that aren't Jinja
expressions. This is a lightweight check to catch obvious mistakes before
full component validation runs later.
"""
if not isinstance(package_config, dict):
raise cv.Invalid("Package contents must be a dict")
if is_remote_package(package_config):
# Package contents must not contain a root `url:` key
raise cv.Invalid("Remote package schema not expected here")
# Validate manually since Voluptuous would regenerate dicts and lose metadata
# such as ESPHomeDataBase
for k, v in package_config.items():
if not isinstance(k, str):
raise cv.Invalid("Package content keys must be strings")
if isinstance(v, (dict, list, Remove, yaml_util.IncludeFile)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}, switch: !include switches.yaml
if v is None:
continue # e.g. web_server:
if isinstance(v, str) and has_jinja(v):
# e.g: remote package shorthand:
# package_name: github://esphome/repo/file.yaml@${ branch }, or:
# switch: ${ expression that evals to a switch }
continue
raise cv.Invalid("Invalid component content in package definition")
return package_config
def expand_file_to_files(config: dict):
if CONF_FILE in config:
new_config = config
new_config[CONF_FILES] = [config[CONF_FILE]]
del new_config[CONF_FILE]
return new_config
return config
def validate_yaml_filename(value):
value = cv.string(value)
if not (value.endswith(".yaml") or value.endswith(".yml")):
raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.")
return value
def validate_source_shorthand(value):
if not isinstance(value, str):
raise cv.Invalid("Git URL shorthand only for strings")
git_file = git.GitFile.from_shorthand(value)
conf = {
CONF_URL: git_file.git_url,
CONF_FILE: git_file.filename,
}
if git_file.ref:
conf[CONF_REF] = git_file.ref
return REMOTE_PACKAGE_SCHEMA(conf)
def deprecate_single_package(config: dict) -> dict:
_LOGGER.warning(
"""
Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated.
This method for including packages will go away in 2026.7.0
Please use a list instead:
packages:
- !include mypackage.yaml
See https://github.com/esphome/esphome/pull/12116
"""
)
return config
REMOTE_PACKAGE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_URL): cv.url,
cv.Optional(CONF_PATH): cv.string,
cv.Optional(CONF_USERNAME): cv.string,
cv.Optional(CONF_PASSWORD): cv.string,
cv.Exclusive(CONF_FILE, CONF_FILES): validate_yaml_filename,
cv.Exclusive(CONF_FILES, CONF_FILES): cv.All(
cv.ensure_list(
cv.Any(
validate_yaml_filename,
cv.Schema(
{
cv.Required(CONF_PATH): validate_yaml_filename,
cv.Optional(CONF_VARS, default={}): cv.Schema(
{cv.string: object}
),
}
),
)
),
cv.Length(min=1),
),
cv.Optional(CONF_REF): cv.git_ref,
cv.Optional(CONF_REFRESH, default="1d"): cv.All(
cv.string, cv.source_refresh
),
}
),
cv.has_at_least_one_key(CONF_FILE, CONF_FILES),
expand_file_to_files,
)
PACKAGE_SCHEMA = cv.Any( # A package definition is either:
validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or
REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or
yaml_util.IncludeFile, # isinstance check — passes IncludeFile objects through unchanged, or:
valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
# which will have to be fully validated later as per each component's schema.
)
CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
cv.Schema(
{
str: PACKAGE_SCHEMA, # a named dict of package definitions, or
}
),
[PACKAGE_SCHEMA], # a list of package definitions, or
cv.All( # a single package definition (deprecated)
cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package
),
)
def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
"""Clone/update a git repo and load the YAML files listed in the package definition.
Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
can recurse into the loaded packages. Each loaded YAML node is tagged
with any ``vars:`` from the file entry via :func:`yaml_util.add_context`.
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH]
repo_dir, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=actual_refresh,
domain=DOMAIN,
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
)
files: list[dict[str, Any]] = []
if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path
for file in config[CONF_FILES]:
if isinstance(file, str):
files.append({CONF_PATH: file, CONF_VARS: {}})
else:
files.append(file)
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
"""Load a YAML file from a remote package, validating min_version."""
try:
new_yaml = yaml_util.load_yaml(yaml_file)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
min_version = esphome_config.get(CONF_MIN_VERSION)
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
ESPHOME_VERSION
):
raise cv.Invalid(
f"Current ESPHome Version is too old to use"
f" this package: {ESPHOME_VERSION} < {min_version}"
)
return new_yaml
def get_packages(files: list[dict[str, Any]]) -> dict:
packages: dict[str, Any] = {}
for idx, file in enumerate(files):
filename = file[CONF_PATH]
yaml_file: Path = repo_dir / filename
if not yaml_file.is_file():
raise cv.Invalid(
f"{filename} does not exist in repository",
path=[CONF_FILES, idx, CONF_PATH],
)
new_yaml = _load_package_yaml(yaml_file, filename)
new_yaml = yaml_util.add_context(new_yaml, file.get(CONF_VARS))
packages[f"{filename}{idx}"] = new_yaml
return packages
if revert is not None:
# If loading fails, the cached checkout may be stale — revert and retry once.
try:
return {CONF_PACKAGES: get_packages(files)}
except cv.Invalid:
revert()
try:
return {CONF_PACKAGES: get_packages(files)}
except cv.Invalid as err:
raise cv.Invalid(f"Failed to load packages. {err}", path=err.path) from err
return {CONF_PACKAGES: get_packages(files)}
def _walk_package_dict(
packages: dict,
callback: Callable[[dict, ContextVars | None], dict],
context: ContextVars | None,
) -> cv.Invalid | None:
"""Iterate a packages dict in reverse priority order, invoking callback on each entry.
Returns ``None`` on success, or the first :class:`cv.Invalid` error if a callback fails.
"""
for package_name, package_config in reversed(packages.items()):
with cv.prepend_path(package_name):
try:
packages[package_name] = callback(package_config, context)
except cv.Invalid as err:
return err
return None
def _walk_package_list(
packages: list,
callback: Callable[[dict, ContextVars | None], dict],
context: ContextVars | None,
) -> None:
"""Iterate a packages list in reverse priority order, invoking callback on each entry."""
for idx in reversed(range(len(packages))):
with cv.prepend_path(idx):
packages[idx] = callback(packages[idx], context)
def _walk_packages(
config: dict,
callback: Callable[[dict, ContextVars | None], dict],
context: ContextVars | None = None,
validate_deprecated: bool = True,
) -> dict:
"""Walks the packages structure in priority order, invoking ``callback`` on each package definition found.
This function only iterates over the immediate ``packages:`` entries in *config*.
If packages may contain nested ``packages:`` keys, the *callback* is responsible
for recursing by calling ``_walk_packages`` on the returned package config.
"""
if CONF_PACKAGES not in config:
return config
packages = config[CONF_PACKAGES]
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
with cv.prepend_path(CONF_PACKAGES):
if not isinstance(packages, dict):
_walk_package_list(packages, callback, context)
elif (result := _walk_package_dict(packages, callback, context)) is not None:
if not validate_deprecated:
raise result
# Fallback: treat the dict as a single deprecated package.
# Note: this catches *any* cv.Invalid from the callback, which may
# mask real validation errors in named package dicts.
# This block can be removed once the single-package
# deprecation period (2026.7.0) is over.
config[CONF_PACKAGES] = [packages]
return _walk_packages(deprecate_single_package(config), callback, context)
config[CONF_PACKAGES] = packages
return config
def _substitute_package_definition(
package_config: dict | str, context_vars: ContextVars | None
) -> dict | str:
"""Substitute variables in a package definition string or remote package dict.
Only substitutes strings and remote package dicts (URLs, refs, paths).
Local package contents are left untouched — they will be substituted
later during the main substitution pass.
"""
if isinstance(package_config, str) or (
isinstance(package_config, dict) and is_remote_package(package_config)
):
package_config = substitute(
item=package_config,
path=[],
parent_context=context_vars or ContextVars(),
strict_undefined=False,
)
return package_config
def _update_substitutions_context(
parent_context: UserDict,
package_substitutions: dict[str, Any],
) -> None:
"""Resolve and add new substitutions to the parent context.
Skips keys already present (higher-priority sources win).
String values are substituted against the current context so that
cross-references between substitutions are expanded when possible.
"""
for key, value in package_substitutions.items():
if key in parent_context:
continue
if not isinstance(value, str):
parent_context[key] = value
continue
parent_context[key] = substitute(
item=value,
path=[CONF_SUBSTITUTIONS, key],
parent_context=ContextVars(parent_context),
strict_undefined=False,
)
class _PackageProcessor:
"""Stateful processor that resolves packages and collects substitutions.
Packages are processed highest-priority first (later-declared before
earlier-declared) so that their substitutions are available when
resolving lower-priority package definitions. For each entry:
1. Substitute variables in remote package definitions (URLs, refs, paths).
2. Validate against ``PACKAGE_SCHEMA`` and download remote packages.
3. Extract ``substitutions:`` and merge into the shared context
(higher-priority packages win on conflicts).
4. Recurse into any nested ``packages:`` keys.
Command-line substitutions take the highest priority and are never overridden.
"""
def __init__(
self,
substitutions: UserDict,
command_line_substitutions: dict[str, Any] | None,
skip_update: bool,
) -> None:
self.substitutions = substitutions
self.parent_context = UserDict(command_line_substitutions or {})
self.skip_update = skip_update
def resolve_package(
self,
package_config: dict | str | yaml_util.IncludeFile,
context_vars: ContextVars | None,
) -> dict:
"""Resolve a package definition to a concrete ``dict`` and fetch remote packages.
The input may be a ``str`` (git shorthand or Jinja expression), a
``dict`` (remote or local package), or an ``IncludeFile`` whose filename
may itself contain substitution expressions.
The loop handles the case where loading an ``IncludeFile`` yields another
``IncludeFile`` (e.g. a chain of deferred includes). Each iteration:
1. If the current value is an ``IncludeFile``, load it — resolving any
substitutions in its filename first.
2. Substitute variables in the resulting value (for strings and remote
package dicts).
3. Validate against ``PACKAGE_SCHEMA``. If the result is a ``dict``,
the loop exits; otherwise another iteration is needed.
Raises ``cv.Invalid`` if the chain has not resolved to a ``dict`` after
``MAX_INCLUDE_DEPTH`` iterations.
"""
for _ in range(MAX_INCLUDE_DEPTH):
if isinstance(package_config, yaml_util.IncludeFile):
package_config, _ = resolve_include(
package_config,
[],
context_vars or ContextVars(),
strict_undefined=False,
)
package_config = _substitute_package_definition(
package_config, context_vars
)
package_config = PACKAGE_SCHEMA(package_config)
if isinstance(package_config, dict):
break
else:
raise cv.Invalid(
f"Maximum include nesting depth ({MAX_INCLUDE_DEPTH}) exceeded"
)
if is_remote_package(package_config):
package_config = _process_remote_package(package_config, self.skip_update)
return package_config
def collect_substitutions(self, package_config: dict) -> None:
"""Extract substitutions from a package and merge into the shared context."""
if subs := package_config.pop(CONF_SUBSTITUTIONS, {}):
self.substitutions.data = merge_config(subs, self.substitutions.data)
_update_substitutions_context(self.parent_context, subs)
def process_package(
self, package_config: dict | str, context_vars: ContextVars | None
) -> dict:
"""Resolve a single package and recurse into any nested packages."""
package_config = self.resolve_package(package_config, context_vars)
self.collect_substitutions(package_config)
if CONF_PACKAGES not in package_config:
return package_config
# Push context from !include vars on the package root and on the packages key
context_vars = push_context(package_config, context_vars)
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
return _walk_packages(package_config, self.process_package, context_vars)
def do_packages_pass(
config: dict,
*,
command_line_substitutions: dict[str, Any] | None = None,
skip_update: bool = False,
) -> dict:
"""Load, validate, and flatten all packages in the config.
Returns the config with all packages loaded in-place (but not yet merged)
and a consolidated ``substitutions:`` block restored at the front.
"""
if CONF_PACKAGES not in config:
return config
substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {}))
processor = _PackageProcessor(
substitutions, command_line_substitutions, skip_update
)
_update_substitutions_context(processor.parent_context, substitutions)
context_vars = push_context(
config[CONF_PACKAGES], ContextVars(processor.parent_context)
)
_walk_packages(config, processor.process_package, context_vars)
if substitutions:
config[CONF_SUBSTITUTIONS] = substitutions.data
return config
def merge_packages(config: dict) -> dict:
"""Flatten the ``packages:`` tree into the main config.
Collects every package (including nested ones) into a flat list in
priority order, then merges them into *config* using :func:`merge_config`.
Higher-priority packages (declared later) override lower-priority ones.
The ``packages:`` key is removed from the returned config.
Must be called after :func:`do_packages_pass` has resolved all packages.
"""
if CONF_PACKAGES not in config:
return config
# Build flat list of all package configs to merge in priority order:
merge_list: list[dict] = []
def process_package_callback(
package_config: dict, context: ContextVars | None
) -> dict:
"""This will be called for each package found in the config."""
merge_list.append(package_config)
return _walk_packages(package_config, process_package_callback)
_walk_packages(config, process_package_callback, validate_deprecated=False)
# Merge all packages into the main config:
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
del config[CONF_PACKAGES]
return config