Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
535 lines
19 KiB
Python
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
|