[substitutions][core] Expand templated !include paths to on-disk candidates during bundle discovery (#17647)

This commit is contained in:
J. Nick Koston
2026-08-03 20:48:11 -05:00
committed by GitHub
parent 25d5985775
commit 4f67932e0d
6 changed files with 648 additions and 23 deletions
+244 -1
View File
@@ -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."""