[substitutions] Support !include with a condition (#19455)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jeff Brown
2026-10-02 07:27:29 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 8617368294
commit 12b016e3d2
12 changed files with 420 additions and 76 deletions
+16 -3
View File
@@ -353,7 +353,7 @@ def resolve_include(
strict_undefined: bool = True,
errors: ErrList | None = None,
) -> Any:
"""Resolve an include, substituting the filename if needed.
"""Resolve an include, substituting the condition and filename if needed.
Note: no path-traversal validation is performed on the resolved filename.
A substitution that resolves to an absolute path will bypass the parent
@@ -362,6 +362,19 @@ def resolve_include(
values (including command-line substitutions), so path restrictions are
an explicit non-goal here.
"""
if isinstance(original_condition := include.condition, str):
condition = str(
_expand_substitutions(
original_condition,
path + ["condition"],
context_vars,
strict_undefined,
errors,
)
)
if condition != original_condition:
include = include.with_condition(condition)
original = include.file
filename = str(
_expand_substitutions(
@@ -372,8 +385,8 @@ def resolve_include(
if substituted:
include = include.with_file(filename)
try:
return include.load()
except esphome.core.EsphomeError as err:
return include.load() if include.should_load() else {}
except (esphome.core.EsphomeError, cv.Invalid) as err:
resolved = f" (expanded from '{original}')" if substituted else ""
raise cv.Invalid(
f"Error including file '{filename}'{resolved}: {err}"
+90 -24
View File
@@ -35,7 +35,7 @@ from esphome.core import (
TimePeriod,
)
from esphome.expression import has_substitution_or_expression
from esphome.helpers import add_class_to_obj
from esphome.helpers import FALSY_BOOL_STRINGS, TRUTHY_BOOL_STRINGS, add_class_to_obj
from esphome.util import OrderedDict, filter_yaml_files
_LOGGER = logging.getLogger(__name__)
@@ -75,6 +75,12 @@ def take_dropped_merge_keys() -> list[tuple[str, str]]:
return CORE.data.pop(_MERGE_WARNINGS_KEY, [])
def _raise_invalid(message: str) -> None:
from voluptuous import Invalid
raise Invalid(message)
class SensitiveStr(str):
"""Marker subclass for validated strings that should be masked in
user-visible YAML output. ``cv.sensitive`` wraps validated values in this
@@ -224,16 +230,18 @@ class IncludeFile:
Created during YAML parsing instead of loading the file immediately,
allowing substitution variables to appear in the filename path
(e.g. ``!include device-${platform}.yaml``). The actual file is
loaded on the first call to ``load()``, and the result is cached.
(e.g. ``!include device-${platform}.yaml``) and in an optional condition.
The actual file is loaded on the first call to ``load()``, and the result
is cached. The client is responsible for testing the condition if needed.
"""
def __init__(
self,
parent_file: Path,
file: str,
vars: dict[str, Any] | None,
yaml_loader: Callable[[Path], Any],
vars: dict[str, Any] | None = None,
condition: bool | str | None = None,
) -> None:
self.parent_file = parent_file
# The raw include text may be a substitution/Jinja expression, so it
@@ -241,8 +249,9 @@ class IncludeFile:
# rewrites "/" to "\", which Jinja then decodes as escapes like
# "\b" -> backspace (issue #18545).
self.file = file
self.vars = vars
self.yaml_loader = yaml_loader
self.vars = vars
self.condition = condition
self._content: Any = _UNSET
def __repr__(self) -> str:
@@ -256,23 +265,62 @@ class IncludeFile:
"""
if self._content is not _UNSET:
return self._content
if self.has_unresolved_expressions():
from voluptuous import Invalid
raise Invalid(
if self.has_unresolved_file():
_raise_invalid(
f"Cannot load include with unresolved substitutions: {self.file}"
)
self._content = self.yaml_loader(self.parent_file.parent / self.file)
self._content = add_context(self._content, self.vars)
return self._content
def has_unresolved_expressions(self) -> bool:
def has_unresolved_file(self) -> bool:
"""Check if the filename contains substitution variables or Jinja expressions."""
return has_substitution_or_expression(self.file)
def should_load(self) -> bool:
"""Evaluates the condition and returns True if the file should be loaded."""
if self.condition is None:
return True
if isinstance(self.condition, bool):
return self.condition
if self.has_unresolved_condition():
_raise_invalid(
f"Cannot evaluate include condition for '{self.file}' with unresolved substitutions: {self.condition}"
)
value = self.condition.lower()
if value in TRUTHY_BOOL_STRINGS:
return True
if value not in FALSY_BOOL_STRINGS:
_raise_invalid(
f"Cannot convert include condition for '{self.file}' to a boolean, please use 'true' or 'false': {self.condition}"
)
return False
def has_unresolved_condition(self) -> bool:
"""Check if the condition contains substitution variables or Jinja expressions."""
return isinstance(self.condition, str) and has_substitution_or_expression(
self.condition
)
def with_file(self, file: str) -> IncludeFile:
"""Clone this include with *file* as the filename."""
return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader)
return IncludeFile(
self.parent_file,
file,
self.yaml_loader,
vars=self.vars,
condition=self.condition,
)
def with_condition(self, condition: bool | str | None) -> IncludeFile:
"""Clone this include with *condition* as the condition."""
return IncludeFile(
self.parent_file,
self.file,
self.yaml_loader,
vars=self.vars,
condition=condition,
)
def _is_visible_path(rel: Path) -> bool:
@@ -434,7 +482,7 @@ def force_load_include_files(
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
if obj.has_unresolved_file():
_load_include_candidates(
obj,
warn_on_unresolved=warn_on_unresolved,
@@ -578,14 +626,21 @@ def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) -
for _ in range(_MAX_MERGE_INCLUDE_DEPTH):
if not isinstance(value, IncludeFile):
break
if value.has_unresolved_expressions():
if value.has_unresolved_file():
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
node.start_mark,
"Substitution in include filename with merge keys is not supported yet.",
value_node.start_mark,
)
value = value.load()
if value.has_unresolved_condition():
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
node.start_mark,
"Substitution in include condition with merge keys is not supported yet.",
value_node.start_mark,
)
value = value.load() if value.should_load() else {}
else:
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
@@ -791,11 +846,11 @@ class ESPHomeLoaderMixin:
@_add_data_ref
def construct_include(self, node: yaml.Node) -> Any:
from esphome.const import CONF_VARS
from esphome.const import CONF_CONDITION, CONF_FILE, CONF_VARS
def extract_file_vars(node):
def extract_fields(node):
fields = self.construct_yaml_map(node)
file = fields.get("file")
file = fields.get(CONF_FILE)
if file is None:
raise yaml.MarkedYAMLError("Must include 'file'", node.start_mark)
if not isinstance(file, str):
@@ -803,14 +858,21 @@ class ESPHomeLoaderMixin:
"Include 'file' must be a string", node.start_mark
)
vars = fields.get(CONF_VARS)
return file, vars
condition = fields.get(CONF_CONDITION)
if condition is not None and not isinstance(condition, (bool, str)):
raise yaml.MarkedYAMLError(
"Include 'condition' must be a boolean or string", node.start_mark
)
return file, vars, condition
if isinstance(node, yaml.nodes.MappingNode):
file, vars = extract_file_vars(node)
file, vars, condition = extract_fields(node)
else:
file, vars = node.value, None
file, vars, condition = node.value, None, None
return IncludeFile(self.name, file, vars, self.yaml_loader)
return IncludeFile(
self.name, file, self.yaml_loader, vars=vars, condition=condition
)
# Directory includes (!include_dir_*) load eagerly during YAML parsing
# because their paths are directory names, not individual files, and
@@ -967,7 +1029,7 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An
# Top-level !include returns a deferred IncludeFile; resolve it so
# callers always receive the final content.
if isinstance(res, IncludeFile):
res = res.load()
res = res.load() if res.should_load() else {}
return res
@@ -1358,8 +1420,12 @@ class ESPHomeDumper(yaml.SafeDumper):
return self.represent_scalar(tag="!remove", value=value.value)
def represent_include_file(self, value):
if value.vars:
mapping = {"file": value.file, "vars": value.vars}
if value.vars or value.condition is not None:
mapping = {"file": value.file}
if value.vars:
mapping["vars"] = value.vars
if value.condition is not None:
mapping["condition"] = value.condition
return self.represent_mapping(
tag="!include", mapping=mapping, flow_style=False
)
@@ -13,3 +13,10 @@ test_list:
- a: alpha
- b: beta
- c: The value of C is $c
- {}
- values:
- var1: '1'
- a: alpha
- b: delta
- c: The value of C is saw
- {}
@@ -13,3 +13,22 @@ test_list:
file: inc1.yaml
vars:
b: "beta"
condition: true
- !include
file: inc1.yaml
vars:
b: "gamma"
c: "saw"
condition: false
- !include
file: inc1.yaml
vars:
b: "delta"
c: "saw"
condition: ${var1 == "1"}
- !include
file: inc1.yaml
vars:
b: "epsilon"
c: "saw"
condition: ${var1 == "2"}
@@ -7,4 +7,10 @@ test_list:
file: inc3.yaml
vars:
y: 8
condition: true
<<: !include
file: inc3.yaml
vars:
y: 10
condition: false
after: Content after
@@ -0,0 +1,2 @@
---
true
@@ -11,8 +11,8 @@ esphome:
# should be substituted as 'original',
# not overwritten by vars in the !include above
name: ${name}
name_add_mac_suffix: true
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}}
name_add_mac_suffix: !include includes/true.yaml
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}, condition: "true"}
esp8266:
board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}}
board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}, condition: true}
@@ -0,0 +1,3 @@
---
esphome:
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}, condition: 1}
@@ -0,0 +1,3 @@
---
esphome:
libraries: !include {vars: {var1: Wire}}
+1 -1
View File
@@ -1384,7 +1384,7 @@ def test_force_load_skips_duplicate_include_file() -> None:
self.parent_file = Path("root.yaml")
self.load_calls = 0
def has_unresolved_expressions(self) -> bool:
def has_unresolved_file(self) -> bool:
return False
def load(self) -> dict[str, Any]:
+83 -12
View File
@@ -573,6 +573,65 @@ def test_undefined_variable_warning(
assert "'undefined_var' is undefined" in caplog.text
def test_undefined_variable_warning_avoided_by_include_condition(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Compare two different ways to conditionally enable an !include file
that defines and uses new variables. One way generates a undefined
variable warning when disabled and the other does not."""
include_file = tmp_path / "include.yaml"
include_file.write_text("substitutions:\n var: 1\nresult: ${var}\n")
method_a_file = tmp_path / "method_a.yaml"
method_a_file.write_text(
"packages:\n"
" - !include\n"
" file: include.yaml\n"
" condition: ${enable == 'y'}\n"
)
method_b_file = tmp_path / "method_b.yaml"
method_b_file.write_text(
"substitutions:\n"
" content: !include include.yaml\n"
"packages:\n"
" - ${content if enable == 'y' else {}}\n"
)
def process_file(file, enable):
with caplog.at_level(logging.WARNING):
args = {"enable": enable}
config = yaml_util.load_yaml(file)
config = do_packages_pass(config, command_line_substitutions=args)
return substitutions.do_substitution_pass(
config, command_line_substitutions=args
)
# Case: method A with include enabled produces a result and no warnings
config = process_file(method_a_file, "y")
assert caplog.text == ""
assert config["packages"][0]["result"] == 1
# Case: method A with include disabled produces no result and no warnings
# because the file was not even loaded
config = process_file(method_a_file, "n")
assert caplog.text == ""
assert config["packages"][0] == {}
# Case: method B with include enabled produces a result and no warnings (same as method A)
config = process_file(method_b_file, "y")
assert caplog.text == ""
assert config["packages"][0]["result"] == 1
# Case: method B with include disabled produces no result and an undefined variable warning
# because the file is loaded into a variable and its own variables cannot be expanded
# (different from method A)
config = process_file(method_b_file, "n")
assert (
"Could not resolve substitution variable 'content': 'var' is undefined"
in caplog.text
)
assert config["packages"][0] == {}
def test_password_field_warnings_suppressed(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -717,11 +776,9 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None:
# Each call to the loader returns a fresh IncludeFile pointing at itself,
# so PACKAGE_SCHEMA always sees an IncludeFile and never a dict.
def always_returns_include(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, always_returns_include)
return yaml_util.IncludeFile(parent, path.name, always_returns_include)
package_config = yaml_util.IncludeFile(
parent, "test.yaml", None, always_returns_include
)
package_config = yaml_util.IncludeFile(parent, "test.yaml", always_returns_include)
processor = _PackageProcessor({}, None)
with pytest.raises(
cv.Invalid,
@@ -791,6 +848,22 @@ def test_include_filename_jinja_expression_with_path_separator(
assert config["result"] == {"value": 42}
def test_include_condition_substitution_undefined_var(tmp_path: Path) -> None:
"""!include with an undefined substitution variable raises cv.Invalid.
The error message must reference the unresolved condition template so the
user knows which include failed, rather than seeing a bare file-not-found.
"""
main_file = tmp_path / "main.yaml"
main_file.write_text(
"result: !include\n file: file.yaml\n condition: ${undefined_var}\n"
)
config = yaml_util.load_yaml(main_file)
with pytest.raises(cv.Invalid, match=r"\$\{undefined_var\}"):
substitutions.do_substitution_pass(config)
def test_raise_first_undefined_logs_extras_at_debug(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -838,7 +911,7 @@ def test_do_substitution_pass_included_substitutions_must_be_mapping(
def loader(path: Path):
return ["not", "a", "mapping"]
include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader)
include = yaml_util.IncludeFile(parent, "subs.yaml", loader)
config = OrderedDict({CONF_SUBSTITUTIONS: include})
with pytest.raises(
@@ -861,7 +934,7 @@ def test_do_packages_pass_included_substitutions_must_be_mapping(
def loader(path: Path):
return ["not", "a", "mapping"]
include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader)
include = yaml_util.IncludeFile(parent, "subs.yaml", loader)
config = OrderedDict(
{
CONF_SUBSTITUTIONS: include,
@@ -888,9 +961,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No
def loader(path: Path):
raise EsphomeError(f"Error reading file {path}: No such file")
package_config = yaml_util.IncludeFile(
parent, "${undefined_var}.yaml", None, loader
)
package_config = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", loader)
processor = _PackageProcessor({}, None)
with pytest.raises(cv.Invalid, match="unresolved substitutions"):
processor.resolve_package(package_config, substitutions.ContextVars(), [])
@@ -906,7 +977,7 @@ def test_resolve_include_error_shows_expanded_from_when_substituted(
def failing_loader(_path: Path) -> None:
raise EsphomeError("File not found")
include = yaml_util.IncludeFile(parent, "${device}.yaml", None, failing_loader)
include = yaml_util.IncludeFile(parent, "${device}.yaml", failing_loader)
context = substitutions.ContextVars({"device": "my_device"})
with pytest.raises(cv.Invalid) as exc_info:
@@ -927,7 +998,7 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename(
def failing_loader(_path: Path) -> None:
raise EsphomeError("File not found")
include = yaml_util.IncludeFile(parent, "literal.yaml", None, failing_loader)
include = yaml_util.IncludeFile(parent, "literal.yaml", failing_loader)
with pytest.raises(cv.Invalid) as exc_info:
substitutions.resolve_include(include, [], substitutions.ContextVars())
@@ -946,7 +1017,7 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
included.write_text('!lambda |-\n return "${foo}";\n')
include = yaml_util.IncludeFile(
tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml
tmp_path / "main.yaml", "lambda.yaml", yaml_util.load_yaml, vars={"foo": "bar"}
)
config = OrderedDict({"value": include.load()})
result = substitutions.do_substitution_pass(config)
+187 -33
View File
@@ -43,17 +43,37 @@ def clear_core_frontmatter() -> None:
core.CORE.frontmatter = {}
def test_include_with_vars(fixture_path: Path) -> None:
def test_include(fixture_path: Path) -> None:
"""Test !include with and without vars, with and without conditions"""
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
actual = yaml_util.load_yaml(yaml_file)
actual = substitutions.do_substitution_pass(actual, None)
assert actual["esphome"]["name"] == "original"
assert actual["esphome"]["name_add_mac_suffix"]
assert actual["esphome"]["libraries"][0] == "Wire"
assert actual["esp8266"]["board"] == "nodemcu"
assert actual["wifi"]["ssid"] == "my_custom_ssid"
def test_include_with_no_file(fixture_path: Path) -> None:
"""Ensure that an error is emitted when the file field is missing."""
yaml_file = fixture_path / "yaml_util" / "includetest_no_file.yaml"
with pytest.raises(EsphomeError, match=r"Must include 'file'"):
yaml_util.load_yaml(yaml_file)
def test_include_with_invalid_condition_type(fixture_path: Path) -> None:
"""Ensure that an error is emitted when the condition field is an invalid type."""
yaml_file = fixture_path / "yaml_util" / "includetest_invalid_condition_type.yaml"
with pytest.raises(
EsphomeError, match=r"Include 'condition' must be a boolean or string"
):
yaml_util.load_yaml(yaml_file)
def test_loading_a_missing_file(fixture_path):
"""We throw EsphomeError when loading a missing file."""
yaml_file = fixture_path / "yaml_util" / "missing.yaml"
@@ -64,7 +84,7 @@ def test_loading_a_missing_file(fixture_path):
def test_parsing_with_custom_loader(fixture_path):
"""Test custom loader used for vscode connection
Default loader is tested in test_include_with_vars
Default loader is tested in test_include
"""
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
@@ -78,10 +98,11 @@ def test_parsing_with_custom_loader(fixture_path):
# substitute config to expand includes:
substitutions.substitute(config, [], substitutions.ContextVars(), False)
assert len(loader_calls) == 3
assert len(loader_calls) == 4
assert loader_calls[0].parts[-2:] == ("includes", "included.yaml")
assert loader_calls[1].parts[-2:] == ("includes", "list.yaml")
assert loader_calls[2].parts[-2:] == ("includes", "scalar.yaml")
assert loader_calls[1].parts[-2:] == ("includes", "true.yaml")
assert loader_calls[2].parts[-2:] == ("includes", "list.yaml")
assert loader_calls[3].parts[-2:] == ("includes", "scalar.yaml")
def test_construct_secret_simple(fixture_path: Path) -> None:
@@ -570,7 +591,7 @@ def test_represent_remove() -> None:
def test_represent_include_file() -> None:
"""Test that IncludeFile objects are dumped as !include scalars."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {}
Path("/fake/main.yaml"), "path/to/file.yaml", lambda _: {}
)
assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n"
@@ -580,13 +601,27 @@ def test_represent_include_file_with_vars() -> None:
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"),
"path/to/file.yaml",
{"key": "value"},
lambda _: {},
vars={"key": "value"},
)
assert (
yaml_util.dump({"key": include})
== "key: !include\n file: path/to/file.yaml\n vars:\n key: value\n"
)
def test_represent_include_file_with_condition() -> None:
"""Test that IncludeFile with condition is dumped as !include mapping form."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"),
"path/to/file.yaml",
lambda _: {},
condition="true",
)
assert (
yaml_util.dump({"key": include})
== "key: !include\n file: path/to/file.yaml\n condition: 'true'\n"
)
result = yaml_util.dump({"key": include})
assert "!include" in result
assert "file: path/to/file.yaml" in result
assert "key: value" in result
def test_represent_include_file_with_data_base_mixin() -> None:
@@ -596,7 +631,7 @@ def test_represent_include_file_with_data_base_mixin() -> None:
subclass. add_multi_representer must match this subclass through the MRO.
"""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {}
Path("/fake/main.yaml"), "common/spi.yaml", lambda _: {}
)
wrapped = yaml_util.make_data_base(include)
assert isinstance(wrapped, yaml_util.ESPHomeDataBase)
@@ -609,7 +644,7 @@ def test_represent_include_file_with_data_base_mixin() -> None:
def test_include_file_repr(tmp_path: Path) -> None:
"""repr() includes the filename so it appears usefully in error messages."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "some/nested.yaml", None, lambda _: {})
include = yaml_util.IncludeFile(parent, "some/nested.yaml", lambda _: {})
assert repr(include) == "IncludeFile(some/nested.yaml)"
@@ -624,7 +659,7 @@ def test_include_file_load_caches_result(tmp_path: Path) -> None:
call_count += 1
return content
include = yaml_util.IncludeFile(parent, "child.yaml", None, counting_loader)
include = yaml_util.IncludeFile(parent, "child.yaml", counting_loader)
first = include.load()
second = include.load()
@@ -641,7 +676,7 @@ def test_include_file_load_caches_none_result(tmp_path: Path) -> None:
nonlocal call_count
call_count += 1
include = yaml_util.IncludeFile(parent, "empty.yaml", None, counting_loader)
include = yaml_util.IncludeFile(parent, "empty.yaml", counting_loader)
first = include.load()
second = include.load()
@@ -653,7 +688,7 @@ def test_include_file_load_caches_none_result(tmp_path: Path) -> None:
def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> None:
"""load() raises if the filename contains unresolved substitutions or expressions."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", None, lambda _: {})
include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", lambda _: {})
with pytest.raises(cv.Invalid, match="unresolved"):
include.load()
@@ -670,13 +705,83 @@ def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> N
("price-100$.yaml", False), # $ at end, not followed by valid substitution
],
)
def test_include_file_has_unresolved_expressions(
def test_include_file_has_unresolved_file(
tmp_path: Path, filename: str, expected: bool
) -> None:
"""has_unresolved_expressions() detects substitution patterns in the filename."""
"""has_unresolved_file() detects substitution patterns in the filename."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, filename, None, lambda _: {})
assert include.has_unresolved_expressions() == expected
include = yaml_util.IncludeFile(parent, filename, lambda _: {})
assert include.has_unresolved_file() == expected
@pytest.mark.parametrize(
("condition", "expected"),
[
(None, False),
(True, False),
(False, False),
("true", False),
("false", False),
("$has_feature", True), # whole substitution
("${has_feature}", True), # whole substitution
("tr$ue", True), # partial substitution
("$.", False), # malformed substitution
("${1 == 1}", True), # Jinja expression
("${", False), # malformed expression
],
)
def test_include_file_has_unresolved_condition(
tmp_path: Path, condition: bool | str | None, expected: bool
) -> None:
"""has_unresolved_condition() detects substitution patterns in the condition."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(
parent, "device.yaml", lambda _: {}, condition=condition
)
assert include.has_unresolved_condition() == expected
@pytest.mark.parametrize(
("condition", "expected_result_or_error"),
[
(None, True),
(True, True),
(False, False),
("true", True),
("True", True),
("TRUE", True),
("yes", True),
("on", True),
("enable", True),
("false", False),
("False", False),
("FALSE", False),
("no", False),
("off", False),
("disable", False),
(
"$sub",
"Cannot evaluate include condition for 'device.yaml' with unresolved substitutions",
),
("", "Cannot convert include condition for 'device.yaml' to a boolean"),
("trues", "Cannot convert include condition for 'device.yaml' to a boolean"),
],
)
def test_include_file_should_load(
tmp_path: Path,
condition: bool | str | None,
expected_result_or_error: bool | str,
) -> None:
"""should_load() evaluates the condition and raises an error if it is malformed."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(
parent, "device.yaml", lambda _: {}, condition=condition
)
if isinstance(expected_result_or_error, bool):
assert include.should_load() == expected_result_or_error
else:
with pytest.raises(cv.Invalid, match=expected_result_or_error):
include.should_load()
def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None:
@@ -691,16 +796,16 @@ def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> Non
"""A templated filename keeps its verbatim text (issue #18545)."""
parent = tmp_path / "main.yaml"
expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }'
include = yaml_util.IncludeFile(parent, expr, None, lambda _: {})
include = yaml_util.IncludeFile(parent, expr, lambda _: {})
assert include.file == expr
assert include.has_unresolved_expressions()
assert include.has_unresolved_file()
assert repr(include) == f"IncludeFile({expr})"
def test_represent_include_file_templated() -> None:
"""Dumping a templated IncludeFile emits the raw expression unchanged."""
expr = '${ "a/b.yaml" if flag else "../c.yaml" }'
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {})
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, lambda _: {})
assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n"
@@ -710,12 +815,12 @@ def test_include_in_list_context() -> None:
parent = Path("/fake/main.yaml")
# The nested IncludeFile resolves to a plain string value
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: "gamma")
inner = yaml_util.IncludeFile(parent, "inner.yaml", lambda _: "gamma")
# The outer IncludeFile returns a list whose last element is itself an IncludeFile,
# exercising the substitution pass's ability to recurse into loaded content.
outer = yaml_util.IncludeFile(
parent, "items.yaml", None, lambda _: ["alpha", "beta", inner]
parent, "items.yaml", lambda _: ["alpha", "beta", inner]
)
config = OrderedDict({"values": outer})
@@ -736,15 +841,48 @@ def test_top_level_include_resolved_by_load_yaml(tmp_path: Path) -> None:
assert result["key"] == "value"
@pytest.mark.parametrize(
("condition", "expected_result_or_error"),
[
("true", True),
("false", False),
('"TRUE"', True),
('"FALSE"', False),
('"x"', "Cannot convert include condition for 'child.yaml' to a boolean"),
(
"$sub",
"Cannot evaluate include condition for 'child.yaml' with unresolved substitutions",
),
],
)
def test_top_level_include_with_condition_resolved_by_load_yaml(
tmp_path: Path, condition: bool | str | None, expected_result_or_error: bool | str
) -> None:
"""load_yaml evaluates the condition for a top-level !include."""
child = tmp_path / "child.yaml"
child.write_text("key: value\n")
main = tmp_path / "main.yaml"
main.write_text(f"!include {{ file: child.yaml, condition: {condition} }}\n")
if isinstance(expected_result_or_error, bool):
result = yaml_util.load_yaml(main)
assert isinstance(result, dict)
if expected_result_or_error:
assert result["key"] == "value"
else:
assert result == {}
else:
with pytest.raises(cv.Invalid, match=expected_result_or_error):
result = yaml_util.load_yaml(main)
def test_include_plain_filename_loads_after_deferred_refactor() -> None:
"""!include with a plain filename (no $ expressions) still loads correctly.
Regression guard: the deferred-loading refactor must not break the simple case.
"""
parent = Path("/fake/main.yaml")
include = yaml_util.IncludeFile(
parent, "child.yaml", None, lambda _: {"answer": 42}
)
include = yaml_util.IncludeFile(parent, "child.yaml", lambda _: {"answer": 42})
config = OrderedDict({"result": include})
config = substitutions.do_substitution_pass(config)
@@ -756,7 +894,23 @@ def test_yaml_merge_include_with_filename_substitution_raises() -> None:
"""<<: !include ${expr} raises a clear error — substitutions in merge-key filenames
are not yet supported, and the error message must say so."""
yaml_text = "base:\n existing: value\n <<: !include ${filename}.yaml\n"
with pytest.raises(EsphomeError, match="not supported yet"):
with pytest.raises(
EsphomeError,
match="Substitution in include filename with merge keys is not supported yet",
):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
def test_yaml_merge_include_with_condition_substitution_raises() -> None:
"""<<: !include { file: ${expr}, condition: {} } raises a clear error — substitutions in merge-key conditions
are not yet supported, and the error message must say so."""
yaml_text = "base:\n existing: value\n <<: !include\n file: filename.yaml\n condition: ${expr}\n"
with pytest.raises(
EsphomeError,
match="Substitution in include condition with merge keys is not supported yet",
):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
@@ -775,8 +929,8 @@ def test_yaml_merge_chain_include_resolves() -> None:
"""Chained includes in merge keys resolve through multiple IncludeFile layers."""
parent = Path("/fake/main.yaml")
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: {"x": 1})
outer = yaml_util.IncludeFile(parent, "outer.yaml", None, lambda _: inner)
inner = yaml_util.IncludeFile(parent, "inner.yaml", lambda _: {"x": 1})
outer = yaml_util.IncludeFile(parent, "outer.yaml", lambda _: inner)
yaml_text = "base:\n existing: value\n <<: !include outer.yaml\n"
config = yaml_util.parse_yaml(parent, io.StringIO(yaml_text), lambda _: outer)
@@ -791,7 +945,7 @@ def test_yaml_merge_chain_include_depth_exceeded() -> None:
parent = Path("/fake/main.yaml")
def self_referencing_loader(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, self_referencing_loader)
return yaml_util.IncludeFile(parent, path.name, self_referencing_loader)
yaml_text = "base:\n <<: !include loop.yaml\n"
with pytest.raises(EsphomeError, match="Maximum include chain depth"):
@@ -1061,7 +1215,7 @@ class _StubInclude:
self._raise = raise_on_load
self.load_calls = 0
def has_unresolved_expressions(self) -> bool:
def has_unresolved_file(self) -> bool:
return self._unresolved
def load(self) -> object: