mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 16:30:22 +00:00
[substitutions][core] Expand templated !include paths to on-disk candidates during bundle discovery (#17647)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from collections import ChainMap
|
||||
from itertools import product
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import esphome
|
||||
@@ -7,6 +9,7 @@ from esphome import core
|
||||
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS
|
||||
from esphome.expression import JINJA_PROG
|
||||
from esphome.types import ConfigType
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import (
|
||||
@@ -27,6 +30,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ContextVars = ChainMap[str, Any]
|
||||
ErrList = list[tuple[UndefinedError, DocumentPath, Any]]
|
||||
|
||||
# Candidate-pattern shaping for include_candidate_patterns.
|
||||
_ADJACENT_WILDCARDS_RE = re.compile(r"\*+")
|
||||
# Dots are included so a variant like `../*` counts as fully dynamic too;
|
||||
# it would otherwise glob everything in the parent directory.
|
||||
_WILDCARDS_ONLY_RE = re.compile(r"[*./\\]+")
|
||||
_GLOB_META_RE = re.compile(r"[?\[]")
|
||||
_STRING_LITERAL_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"")
|
||||
|
||||
# Module-level instance is safe: context_vars is passed per-call, and context_trace
|
||||
# is stack-saved/restored within expand(). Not thread-safe — only use from one thread.
|
||||
jinja = Jinja()
|
||||
@@ -360,9 +371,7 @@ def resolve_include(
|
||||
)
|
||||
substituted = filename != original_str
|
||||
if substituted:
|
||||
include = IncludeFile(
|
||||
include.parent_file, filename, include.vars, include.yaml_loader
|
||||
)
|
||||
include = include.with_file(filename)
|
||||
try:
|
||||
return include.load()
|
||||
except esphome.core.EsphomeError as err:
|
||||
@@ -374,6 +383,45 @@ def resolve_include(
|
||||
) from err
|
||||
|
||||
|
||||
def include_candidate_patterns(value: str) -> list[str]:
|
||||
"""Expand a substitution/Jinja-templated path into glob-style candidate patterns.
|
||||
|
||||
Mirrors the two phases of :func:`_expand_substitutions` without variable
|
||||
values: ``$var`` / ``${var}`` references become ``*`` and each remaining
|
||||
Jinja expression contributes one pattern per quoted string literal it
|
||||
holds (``*`` when it holds none), so every conditional branch is a
|
||||
candidate — deliberately over-inclusive. Emitted wildcard patterns are
|
||||
glob-safe: adjacent wildcards collapse (no recursive ``**``), ``[`` /
|
||||
``?`` from the filename text are escaped, and variants reduced to
|
||||
nothing but wildcards, dots and separators are dropped so a fully
|
||||
dynamic filename never expands to "everything in the directory",
|
||||
including via a ``../*`` parent traversal.
|
||||
"""
|
||||
# Replacing $var / ${var} first also keeps JINJA_PROG's first-} span
|
||||
# matching correct for references nested inside string literals, the
|
||||
# same ordering _expand_substitutions relies on.
|
||||
value = cv.VARIABLE_PROG.sub("*", value)
|
||||
options = [
|
||||
[a or b for a, b in _STRING_LITERAL_RE.findall(expr)] or ["*"]
|
||||
for expr in JINJA_PROG.findall(value)
|
||||
]
|
||||
|
||||
variants: list[str] = []
|
||||
for combination in product(*options):
|
||||
replacements = iter(combination)
|
||||
spliced = JINJA_PROG.sub(lambda _, _next=replacements: next(_next), value)
|
||||
variants.append(_ADJACENT_WILDCARDS_RE.sub("*", spliced))
|
||||
|
||||
patterns: list[str] = []
|
||||
for variant in dict.fromkeys(variants):
|
||||
if not variant or _WILDCARDS_ONLY_RE.fullmatch(variant):
|
||||
continue
|
||||
if "*" in variant:
|
||||
variant = _GLOB_META_RE.sub(r"[\g<0>]", variant)
|
||||
patterns.append(variant)
|
||||
return patterns
|
||||
|
||||
|
||||
def _substitute_include(
|
||||
include: IncludeFile,
|
||||
path: DocumentPath,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Helpers for detecting substitution variables and Jinja expressions."""
|
||||
"""Helpers for detecting and matching substitution variables and Jinja expressions."""
|
||||
|
||||
import re
|
||||
|
||||
@@ -8,7 +8,7 @@ SUBSTITUTION_VARIABLE_PROG = re.compile(
|
||||
rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})"
|
||||
)
|
||||
|
||||
_JINJA_RE = re.compile(
|
||||
JINJA_PROG = re.compile(
|
||||
r"<%.+?%>" # Block: <% ... %>
|
||||
r"|\$\{[^}]+\}", # Braced: ${ ... }
|
||||
flags=re.MULTILINE,
|
||||
@@ -17,7 +17,7 @@ _JINJA_RE = re.compile(
|
||||
|
||||
def has_jinja(value: str) -> bool:
|
||||
"""Check if a string contains Jinja expressions."""
|
||||
return _JINJA_RE.search(value) is not None
|
||||
return JINJA_PROG.search(value) is not None
|
||||
|
||||
|
||||
def has_substitution_or_expression(value: str) -> bool:
|
||||
|
||||
+159
-15
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from voluptuous import Invalid
|
||||
import yaml
|
||||
from yaml import SafeLoader as PurePythonLoader
|
||||
import yaml.constructor
|
||||
@@ -253,8 +254,6 @@ class IncludeFile:
|
||||
if self._content is not _UNSET:
|
||||
return self._content
|
||||
if self.has_unresolved_expressions():
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
raise Invalid(
|
||||
f"Cannot load include with unresolved substitutions: {self.file}"
|
||||
)
|
||||
@@ -266,12 +265,133 @@ class IncludeFile:
|
||||
"""Check if the filename contains substitution variables or Jinja expressions."""
|
||||
return has_substitution_or_expression(str(self.file))
|
||||
|
||||
def with_file(self, file: Path | str) -> IncludeFile:
|
||||
"""Clone this include with *file* as the filename."""
|
||||
return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader)
|
||||
|
||||
|
||||
def _is_visible_path(rel: Path) -> bool:
|
||||
"""Report whether no component of *rel* is hidden (``..`` stays valid)."""
|
||||
return all(part == ".." or _is_file_valid(part) for part in rel.parts)
|
||||
|
||||
|
||||
def _glob_include_candidates(parent_dir: Path, pattern: str) -> list[Path]:
|
||||
"""
|
||||
Expand a candidate glob under *parent_dir*, keeping hidden files out.
|
||||
|
||||
An un-globbable pattern (absolute, or one the filesystem rejects) is
|
||||
skipped instead of crashing discovery.
|
||||
"""
|
||||
try:
|
||||
found_paths = parent_dir.glob(pattern)
|
||||
return [
|
||||
rel
|
||||
for found in found_paths
|
||||
if _is_visible_path(rel := found.relative_to(parent_dir))
|
||||
]
|
||||
except (NotImplementedError, ValueError) as err:
|
||||
_LOGGER.debug("Cannot glob include pattern %r: %s", pattern, err)
|
||||
return []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("I/O error globbing include pattern %r: %s", pattern, err)
|
||||
return []
|
||||
|
||||
|
||||
def _candidate_include_paths(include: IncludeFile) -> list[Path]:
|
||||
"""Enumerate resolved files an expression-templated ``!include`` could select.
|
||||
|
||||
Wildcard patterns from ``substitutions.include_candidate_patterns`` glob
|
||||
under the including file's directory with hidden files excluded (like
|
||||
``!include_dir_*``); literal branch patterns are tried verbatim. Matches
|
||||
still carrying expression markers or pointing back at the including file
|
||||
are skipped.
|
||||
"""
|
||||
# Deferred import — the substitutions component imports this module.
|
||||
from esphome.components.substitutions import include_candidate_patterns
|
||||
|
||||
parent_dir = include.parent_file.parent
|
||||
parent_resolved = include.parent_file.resolve()
|
||||
candidates: list[Path] = []
|
||||
for pattern in include_candidate_patterns(str(include.file)):
|
||||
if "*" in pattern:
|
||||
matches = sorted(_glob_include_candidates(parent_dir, pattern))
|
||||
else:
|
||||
matches = [Path(pattern)]
|
||||
for match in matches:
|
||||
if has_substitution_or_expression(str(match)):
|
||||
continue
|
||||
candidate = parent_dir / match
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
resolved = candidate.resolve()
|
||||
if resolved == parent_resolved:
|
||||
continue
|
||||
candidates.append(resolved)
|
||||
return candidates
|
||||
|
||||
|
||||
def _load_include_candidates(
|
||||
include: IncludeFile,
|
||||
*,
|
||||
warn_on_unresolved: bool,
|
||||
seen: set[int],
|
||||
expanded_paths: set[Path],
|
||||
keepalive: list[Any],
|
||||
) -> None:
|
||||
"""Load every filesystem candidate for an unresolved ``IncludeFile``."""
|
||||
log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug
|
||||
candidates = _candidate_include_paths(include)
|
||||
if not candidates:
|
||||
log(
|
||||
"Cannot resolve !include %s (referenced from %s) with substitutions in path",
|
||||
include.file,
|
||||
include.parent_file,
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Expanding !include %s (referenced from %s) to %d candidate file(s)",
|
||||
include.file,
|
||||
include.parent_file,
|
||||
len(candidates),
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate in expanded_paths:
|
||||
continue
|
||||
expanded_paths.add(candidate)
|
||||
try:
|
||||
loaded = include.with_file(candidate).load()
|
||||
except (EsphomeError, Invalid) as err:
|
||||
# Unlike an unresolved pattern (expected during the discovery
|
||||
# re-parse), a matched on-disk candidate that fails to load is a
|
||||
# genuine user error; warn in every mode. The file itself is
|
||||
# still tracked (the load listener fires before parsing), only
|
||||
# its nested includes go undiscovered.
|
||||
_LOGGER.warning(
|
||||
"Failed to load candidate %s for !include %s: %s",
|
||||
candidate,
|
||||
include.file,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
# The throwaway IncludeFile is this tree's only owner; keep the tree
|
||||
# alive so ids recorded in ``seen`` stay unique for the traversal.
|
||||
keepalive.append(loaded)
|
||||
force_load_include_files(
|
||||
loaded,
|
||||
warn_on_unresolved=warn_on_unresolved,
|
||||
_seen=seen,
|
||||
_expanded_paths=expanded_paths,
|
||||
_keepalive=keepalive,
|
||||
)
|
||||
|
||||
|
||||
def force_load_include_files(
|
||||
obj: Any,
|
||||
*,
|
||||
warn_on_unresolved: bool = True,
|
||||
_seen: set[int] | None = None,
|
||||
_expanded_paths: set[Path] | None = None,
|
||||
_keepalive: list[Any] | None = None,
|
||||
) -> None:
|
||||
"""Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree.
|
||||
|
||||
@@ -282,29 +402,41 @@ def force_load_include_files(
|
||||
loader fires and records every reachable file.
|
||||
|
||||
``IncludeFile`` instances whose path contains unresolved substitution
|
||||
variables cannot be loaded. By default a warning is logged for each one;
|
||||
pass ``warn_on_unresolved=False`` (used by discovery paths that run on a
|
||||
fresh re-parse where substitutions haven't been applied yet) to demote it
|
||||
to a debug log.
|
||||
variables or Jinja expressions are expanded against the filesystem and
|
||||
every existing candidate file is loaded, so bundles ship all branches the
|
||||
expression could select. By default a warning is logged when no candidate
|
||||
exists; pass ``warn_on_unresolved=False`` (used by discovery paths that
|
||||
run on a fresh re-parse where substitutions haven't been applied yet) to
|
||||
demote it to a debug log.
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
if _expanded_paths is None:
|
||||
_expanded_paths = set()
|
||||
if _keepalive is None:
|
||||
# ``_seen`` tracks ids, which is only safe while every traversed
|
||||
# object stays alive; candidate trees are otherwise freed between
|
||||
# loop iterations and CPython recycles their addresses, making a
|
||||
# fresh tree look already seen. Discovery is a one-shot operation,
|
||||
# so holding the parsed trees costs nothing.
|
||||
_keepalive = []
|
||||
|
||||
if isinstance(obj, IncludeFile):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
if obj.has_unresolved_expressions():
|
||||
log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug
|
||||
log(
|
||||
"Cannot resolve !include %s (referenced from %s) with substitutions in path",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
_load_include_candidates(
|
||||
obj,
|
||||
warn_on_unresolved=warn_on_unresolved,
|
||||
seen=_seen,
|
||||
expanded_paths=_expanded_paths,
|
||||
keepalive=_keepalive,
|
||||
)
|
||||
return
|
||||
try:
|
||||
loaded = obj.load()
|
||||
except EsphomeError as err:
|
||||
except (EsphomeError, Invalid) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to load !include %s (referenced from %s): %s",
|
||||
obj.file,
|
||||
@@ -313,7 +445,11 @@ def force_load_include_files(
|
||||
)
|
||||
return
|
||||
force_load_include_files(
|
||||
loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen
|
||||
loaded,
|
||||
warn_on_unresolved=warn_on_unresolved,
|
||||
_seen=_seen,
|
||||
_expanded_paths=_expanded_paths,
|
||||
_keepalive=_keepalive,
|
||||
)
|
||||
elif isinstance(obj, dict):
|
||||
if id(obj) in _seen:
|
||||
@@ -321,7 +457,11 @@ def force_load_include_files(
|
||||
_seen.add(id(obj))
|
||||
for value in obj.values():
|
||||
force_load_include_files(
|
||||
value, warn_on_unresolved=warn_on_unresolved, _seen=_seen
|
||||
value,
|
||||
warn_on_unresolved=warn_on_unresolved,
|
||||
_seen=_seen,
|
||||
_expanded_paths=_expanded_paths,
|
||||
_keepalive=_keepalive,
|
||||
)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
if id(obj) in _seen:
|
||||
@@ -329,7 +469,11 @@ def force_load_include_files(
|
||||
_seen.add(id(obj))
|
||||
for item in obj:
|
||||
force_load_include_files(
|
||||
item, warn_on_unresolved=warn_on_unresolved, _seen=_seen
|
||||
item,
|
||||
warn_on_unresolved=warn_on_unresolved,
|
||||
_seen=_seen,
|
||||
_expanded_paths=_expanded_paths,
|
||||
_keepalive=_keepalive,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1248,7 +1248,8 @@ def test_discover_files_deeply_nested_include(tmp_path: Path) -> None:
|
||||
def test_discover_files_nested_include_unresolved_substitution(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""!include with substitution vars in path cannot be resolved; skipped gracefully."""
|
||||
"""!include with substitution vars in path but no candidate files on disk
|
||||
(the glob's only match is the config itself) is skipped gracefully."""
|
||||
config_dir = _setup_config_dir(tmp_path)
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\nwifi: !include ${platform}.yaml\n"
|
||||
@@ -1262,6 +1263,62 @@ def test_discover_files_nested_include_unresolved_substitution(
|
||||
assert "test.yaml" in paths
|
||||
|
||||
|
||||
def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None:
|
||||
"""The issue-17650 layout: templated package includes chain through a glob
|
||||
candidate into a Jinja conditional whose ``../`` branch is bundled."""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={
|
||||
"includes/esp-basics.yaml": (
|
||||
"packages:\n"
|
||||
" - !include boards/${board}.yaml\n"
|
||||
" - !include keys/${system_name}.yaml\n"
|
||||
),
|
||||
"includes/boards/wemos-d1-mini.yaml": (
|
||||
'packages:\n - !include ${ "NO BT.yaml" if bt else "../empty.yaml" }\n'
|
||||
),
|
||||
"includes/keys/device-a.yaml": "api:\n",
|
||||
"includes/keys/device-b.yaml": "api:\n",
|
||||
"includes/empty.yaml": "{}\n",
|
||||
},
|
||||
)
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\npackages:\n - !include includes/esp-basics.yaml\n"
|
||||
)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "includes/esp-basics.yaml" in paths
|
||||
assert "includes/boards/wemos-d1-mini.yaml" in paths
|
||||
assert "includes/keys/device-a.yaml" in paths
|
||||
assert "includes/keys/device-b.yaml" in paths
|
||||
assert "includes/empty.yaml" in paths
|
||||
|
||||
|
||||
def test_discover_files_candidate_outside_config_dir_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A candidate branch resolving above the config dir is not bundled."""
|
||||
config_dir = _setup_config_dir(tmp_path)
|
||||
(tmp_path / "outside.yaml").write_text("api:\n")
|
||||
(config_dir / "test.yaml").write_text(
|
||||
"esphome:\n name: test\n"
|
||||
'wifi: !include ${ "a.yaml" if x else "../outside.yaml" }\n'
|
||||
)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert not any("outside" in p for p in paths)
|
||||
assert any(
|
||||
"outside config directory" in r.message and "outside.yaml" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_discover_files_nested_include_load_failure(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections import ChainMap
|
||||
from fnmatch import fnmatchcase
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -961,3 +963,134 @@ def test_remote_package_scalar_yaml_raises_helpful_error(
|
||||
msg = str(exc_info.value)
|
||||
assert "mapping at the top level" in msg
|
||||
assert "file1.yaml" in msg
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
pytest.param("wifi.yaml", ["wifi.yaml"], id="literal_passthrough"),
|
||||
pytest.param(
|
||||
"keys/${system_name}.yaml", ["keys/*.yaml"], id="embedded_substitution"
|
||||
),
|
||||
pytest.param(
|
||||
"network/${eth_model}/config.yaml",
|
||||
["network/*/config.yaml"],
|
||||
id="directory_substitution",
|
||||
),
|
||||
pytest.param(
|
||||
"device-$platform.yaml", ["device-*.yaml"], id="unbraced_substitution"
|
||||
),
|
||||
pytest.param("${a}${b}.yaml", ["*.yaml"], id="adjacent_wildcards_collapse"),
|
||||
pytest.param(
|
||||
'${ "a.yaml" if x else "../empty.yaml" }',
|
||||
["a.yaml", "../empty.yaml"],
|
||||
id="conditional_literals",
|
||||
),
|
||||
pytest.param(
|
||||
'pre-${ "a" if c else "b" }.yaml',
|
||||
["pre-a.yaml", "pre-b.yaml"],
|
||||
id="conditional_spliced",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "x.yaml" if a else ("y.yaml" if b else "z.yaml") }',
|
||||
["x.yaml", "y.yaml", "z.yaml"],
|
||||
id="nested_conditional",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "same.yaml" if x else "same.yaml" }',
|
||||
["same.yaml"],
|
||||
id="duplicate_literals_dedupe",
|
||||
),
|
||||
pytest.param('${ "a.yaml" if x }', ["a.yaml"], id="conditional_no_else"),
|
||||
pytest.param(
|
||||
'${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"'
|
||||
' if enable_bluetooth_proxy else "../empty.yaml" }',
|
||||
["NO BLUETOOTH SUPPORT ON ESP8266.yaml", "../empty.yaml"],
|
||||
id="issue_17650_verbatim",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "" if x else "b.yaml" }', ["b.yaml"], id="empty_literal_dropped"
|
||||
),
|
||||
pytest.param(
|
||||
"keys\\${system_name}.yaml",
|
||||
["keys\\*.yaml"],
|
||||
id="backslash_separator",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "it\'s.yaml" if x else "b.yaml" }',
|
||||
["it's.yaml", "b.yaml"],
|
||||
id="apostrophe_in_literal",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "a-${x}.yaml" if c else "b.yaml" }',
|
||||
["a-*.yaml", "b.yaml"],
|
||||
id="substitution_inside_literal",
|
||||
),
|
||||
pytest.param("sensor [${x}].yaml", ["sensor [[]*].yaml"], id="bracket_escaped"),
|
||||
pytest.param(
|
||||
"config?${x}.yaml", ["config[?]*.yaml"], id="question_mark_escaped"
|
||||
),
|
||||
pytest.param(
|
||||
"../${x}/config.yaml", ["../*/config.yaml"], id="ascending_directory"
|
||||
),
|
||||
pytest.param("${file}", [], id="bare_variable_dropped"),
|
||||
pytest.param("../${file}", [], id="ascending_bare_variable_dropped"),
|
||||
pytest.param(
|
||||
'${ name ~ ".yaml" }', [".yaml"], id="dynamic_concat_extracts_literal"
|
||||
),
|
||||
pytest.param("${ if }", [], id="no_literal_expression_dropped"),
|
||||
pytest.param(
|
||||
"<% if x %>a.yaml<% endif %>", ["*a.yaml*"], id="block_statement_globs"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_include_candidate_patterns(value: str, expected: list[str]) -> None:
|
||||
"""Templated include paths expand to glob patterns and branch literals."""
|
||||
assert substitutions.include_candidate_patterns(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("template", "variables"),
|
||||
[
|
||||
pytest.param(
|
||||
"keys/${system_name}.yaml", {"system_name": "esp-buero"}, id="embedded"
|
||||
),
|
||||
pytest.param("device-$platform.yaml", {"platform": "esp32"}, id="unbraced"),
|
||||
pytest.param(
|
||||
"network/${eth_model}/config.yaml", {"eth_model": "eth01"}, id="directory"
|
||||
),
|
||||
pytest.param(
|
||||
'${ "NO BT.yaml" if bt else "../empty.yaml" }',
|
||||
{"bt": True},
|
||||
id="conditional_true",
|
||||
),
|
||||
pytest.param(
|
||||
'${ "NO BT.yaml" if bt else "../empty.yaml" }',
|
||||
{"bt": False},
|
||||
id="conditional_false",
|
||||
),
|
||||
pytest.param('pre-${ "a" if c else "b" }.yaml', {"c": True}, id="spliced"),
|
||||
pytest.param("${a}${b}.yaml", {"a": "x", "b": "y"}, id="adjacent"),
|
||||
pytest.param("sensor [${x}].yaml", {"x": "a"}, id="bracket"),
|
||||
],
|
||||
)
|
||||
def test_include_candidate_patterns_cover_real_expansion(
|
||||
template: str, variables: dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Lockstep pin against the real substitution machinery.
|
||||
|
||||
include_candidate_patterns mirrors _expand_substitutions without
|
||||
variable values (the evaluator returns the one selected branch, so it
|
||||
cannot enumerate candidates itself); this asserts every filename the
|
||||
real pass resolves is covered by a candidate pattern, so a change to
|
||||
reference syntax or expansion order breaks here instead of silently
|
||||
dropping files from bundles.
|
||||
"""
|
||||
resolved = str(
|
||||
substitutions._expand_substitutions(
|
||||
template, [], ChainMap(variables), True, None
|
||||
)
|
||||
)
|
||||
patterns = substitutions.include_candidate_patterns(template)
|
||||
assert any(fnmatchcase(resolved, p) or resolved == p for p in patterns)
|
||||
|
||||
@@ -1003,8 +1003,10 @@ class _StubInclude:
|
||||
load_result: object = None,
|
||||
raise_on_load: EsphomeError | None = None,
|
||||
) -> None:
|
||||
# Default parent lives in a nonexistent directory so unresolved
|
||||
# stubs never glob real files during candidate expansion.
|
||||
self.file = Path(file)
|
||||
self.parent_file = parent_file or Path("/tmp/parent.yaml")
|
||||
self.parent_file = parent_file or Path("/nonexistent/parent.yaml")
|
||||
self._unresolved = unresolved
|
||||
self._load_result = load_result if load_result is not None else {}
|
||||
self._raise = raise_on_load
|
||||
@@ -1182,6 +1184,247 @@ def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None:
|
||||
assert discovered.files.count(wifi_resolved) == 1
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_expands_directory_substitution(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A substitution spanning a directory segment globs across directories."""
|
||||
_write(tmp_path, "network/eth01/config.yaml", "ethernet:\n")
|
||||
_write(tmp_path, "network/eth02/config.yaml", "ethernet:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "network/${eth_model}/config.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "network/eth01/config.yaml").resolve() in resolved
|
||||
assert (tmp_path / "network/eth02/config.yaml").resolve() in resolved
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_loads_both_branches_of_issue_conditional(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Both branch files of the issue-17650 conditional load when present,
|
||||
including the filename with spaces."""
|
||||
_write(tmp_path, "empty.yaml", "{}\n")
|
||||
_write(tmp_path, "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml", "api:\n")
|
||||
_write(
|
||||
tmp_path,
|
||||
"boards/esp8266.yaml",
|
||||
"packages:\n"
|
||||
' - !include ${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"'
|
||||
' if enable_bluetooth_proxy else "../empty.yaml" }\n',
|
||||
)
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "boards/esp8266.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml").resolve() in (
|
||||
resolved
|
||||
)
|
||||
assert (tmp_path / "empty.yaml").resolve() in resolved
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_matches_bracket_filenames(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Glob metacharacters in the literal filename text stay literal."""
|
||||
_write(tmp_path, "sensor [a].yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "sensor [${x}].yaml")
|
||||
)
|
||||
assert "sensor [a].yaml" in {p.name for p in discovered.files}
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_ascending_glob(tmp_path: Path) -> None:
|
||||
"""A templated include reaching into a sibling directory via ``..`` globs."""
|
||||
_write(tmp_path, "shared/common.yaml", "api:\n")
|
||||
_write(tmp_path, "nodes/dev.yaml", "p: !include ../shared/${x}.yaml\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "nodes/dev.yaml")
|
||||
)
|
||||
assert (tmp_path / "shared/common.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_mapping_include_with_vars(tmp_path: Path) -> None:
|
||||
"""The mapping !include form (file + vars) expands a templated filename."""
|
||||
_write(tmp_path, "keys/a.yaml", "pin: ${num}\n")
|
||||
entry = _write(
|
||||
tmp_path,
|
||||
"entry.yaml",
|
||||
"wifi: !include\n file: keys/${n}.yaml\n vars:\n num: 4\n",
|
||||
)
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert (tmp_path / "keys/a.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_absolute_templated_include_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An absolute templated include is skipped gracefully instead of crashing."""
|
||||
shared = tmp_path / "shared"
|
||||
_write(tmp_path, "shared/common.yaml", "api:\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, f"{shared}/${{x}}.yaml")
|
||||
)
|
||||
assert (shared / "common.yaml").resolve() not in discovered.files
|
||||
assert any("Cannot glob include pattern" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_skips_dollar_named_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An on-disk filename containing ``$`` can't load; the glob skips it."""
|
||||
_write(tmp_path, "keys/a.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/b$roken.yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert "a.yaml" in names
|
||||
assert "b$roken.yaml" not in names
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_error_skips_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A filesystem error during candidate globbing warns and skips the include."""
|
||||
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
with (
|
||||
patch.object(Path, "glob", side_effect=OSError("boom")),
|
||||
caplog.at_level("DEBUG", logger="esphome.yaml_util"),
|
||||
):
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert [p.name for p in discovered.files] == ["entry.yaml"]
|
||||
matching = [
|
||||
r.levelname
|
||||
for r in caplog.records
|
||||
if "I/O error globbing include pattern" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_force_load_candidate_failure_warns_by_default(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A broken candidate logs at WARNING outside the discovery re-parse."""
|
||||
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
|
||||
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
force_load_include_files(yaml_util.load_yaml(entry))
|
||||
matching = [
|
||||
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_skips_hidden_files(tmp_path: Path) -> None:
|
||||
"""Candidate globs exclude hidden files, matching ``!include_dir_*``."""
|
||||
_write(tmp_path, "keys/device-a.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/.hidden.yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${name}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert "device-a.yaml" in names
|
||||
assert ".hidden.yaml" not in names
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_bare_expression_not_expanded(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A fully dynamic filename never globs the whole directory."""
|
||||
_write(tmp_path, "sibling.yaml", "api:\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "${file}")
|
||||
)
|
||||
assert (tmp_path / "sibling.yaml").resolve() not in discovered.files
|
||||
assert any(
|
||||
"Cannot resolve !include" in r.message and r.levelname == "DEBUG"
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_self_glob_match_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A glob whose only match is the including file itself claims nothing."""
|
||||
entry = _write_entry_including(tmp_path, "${platform}.yaml")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert [p.name for p in discovered.files] == ["entry.yaml"]
|
||||
assert any("Cannot resolve !include" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_candidate_cycle_terminates(tmp_path: Path) -> None:
|
||||
"""Mutually glob-matching includes expand finitely and capture both files."""
|
||||
_write(tmp_path, "sub/a.yaml", "p: !include ${x}.yaml\n")
|
||||
_write(tmp_path, "sub/b.yaml", "p: !include ${y}.yaml\n")
|
||||
entry = _write(tmp_path, "entry.yaml", "wifi: !include sub/a.yaml\n")
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert names == {"entry.yaml", "a.yaml", "b.yaml"}
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_many_candidates_keep_nested_includes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Every candidate's nested includes are discovered.
|
||||
|
||||
Regression test: the id()-based cycle guard is only safe while every
|
||||
traversed tree stays alive. Candidate trees used to be freed between
|
||||
loop iterations, so CPython recycled their addresses and later
|
||||
candidates' fresh trees were skipped as already seen, silently dropping
|
||||
their nested includes. Needs several candidates to manifest; two were
|
||||
not enough to trigger the reuse."""
|
||||
count = 12
|
||||
for i in range(count):
|
||||
_write(
|
||||
tmp_path, f"keys/k{i}.yaml", f"sensor{i}: !include ../nested/n{i}.yaml\n"
|
||||
)
|
||||
_write(tmp_path, f"nested/n{i}.yaml", f"api{i}: true\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${x}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
expected = {f"n{i}.yaml" for i in range(count)}
|
||||
expected |= {f"k{i}.yaml" for i in range(count)}
|
||||
expected.add("entry.yaml")
|
||||
assert names == expected
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_bad_candidate_still_tracked(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A matched candidate that fails to parse warns even during discovery,
|
||||
stays tracked (the load listener fires before parsing), and doesn't block
|
||||
other candidates."""
|
||||
_write(tmp_path, "keys/good.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${name}.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "keys/good.yaml").resolve() in resolved
|
||||
assert (tmp_path / "keys/bad.yaml").resolve() in resolved
|
||||
matching = [
|
||||
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_tolerates_templated_top_level_include(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A literal include whose entire content is a templated ``!include`` is
|
||||
tracked and skipped instead of aborting discovery."""
|
||||
_write(tmp_path, "wrapper.yaml", "!include ${x}_settings.yaml\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "wrapper.yaml")
|
||||
)
|
||||
assert (tmp_path / "wrapper.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
|
||||
"""`track_yaml_loads` is the building block — sanity-check it resolves
|
||||
symlinks so callers can dedupe by identity."""
|
||||
|
||||
Reference in New Issue
Block a user