[substitutions] substitutions pass and !include redesign (package refactor part 2b) (#14918)
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run CodSpeed benchmarks (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
CI for docker images / Build docker containers (docker, ubuntu-24.04) (push) Has been cancelled
CI for docker images / Build docker containers (docker, ubuntu-24.04-arm) (push) Has been cancelled
CI for docker images / Build docker containers (ha-addon, ubuntu-24.04) (push) Has been cancelled
CI for docker images / Build docker containers (ha-addon, ubuntu-24.04-arm) (push) Has been cancelled
Stale / stale (push) Has been cancelled
Lock closed issues and PRs / lock (push) Has been cancelled
Publish Release / Initialize build (push) Has been cancelled
Publish Release / Build and publish to PyPi (push) Has been cancelled
Publish Release / Build ESPHome amd64 (push) Has been cancelled
Publish Release / Build ESPHome arm64 (push) Has been cancelled
Publish Release / Publish ESPHome docker to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome docker to ghcr (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to ghcr (push) Has been cancelled
Publish Release / deploy-ha-addon-repo (push) Has been cancelled
Publish Release / deploy-esphome-schema (push) Has been cancelled
Publish Release / version-notifier (push) Has been cancelled
Synchronise Device Classes from Home Assistant / Sync Device Classes (push) Has been cancelled

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Javier Peletier
2026-03-23 23:50:28 +01:00
committed by GitHub
parent 332118db56
commit bf6000ef3d
16 changed files with 753 additions and 273 deletions
+8 -1
View File
@@ -226,7 +226,7 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
raise cv.Invalid( raise cv.Invalid(
f"Current ESPHome Version is too old to use this package: {ESPHOME_VERSION} < {min_version}" f"Current ESPHome Version is too old to use this package: {ESPHOME_VERSION} < {min_version}"
) )
new_yaml = yaml_util.substitute_vars(new_yaml, vars) new_yaml = yaml_util.add_context(new_yaml, vars or None)
packages[f"{filename}{idx}"] = new_yaml packages[f"{filename}{idx}"] = new_yaml
except EsphomeError as e: except EsphomeError as e:
raise cv.Invalid( raise cv.Invalid(
@@ -296,6 +296,13 @@ def do_packages_pass(config: dict, skip_update: bool = False) -> dict:
def process_package_callback(package_config: dict) -> dict: def process_package_callback(package_config: dict) -> dict:
"""This will be called for each package found in the config.""" """This will be called for each package found in the config."""
if isinstance(package_config, yaml_util.ConfigContext):
context_vars = package_config.vars
if CONF_PACKAGES in package_config or CONF_URL in package_config:
# Remote package definition: eagerly resolve before PACKAGE_SCHEMA validation.
from esphome.components.substitutions import substitute_context_vars
substitute_context_vars(package_config, context_vars)
package_config = PACKAGE_SCHEMA(package_config) package_config = PACKAGE_SCHEMA(package_config)
if isinstance(package_config, str): if isinstance(package_config, str):
return package_config # Jinja string, skip processing return package_config # Jinja string, skip processing
File diff suppressed because it is too large Load Diff
+21 -73
View File
@@ -1,7 +1,6 @@
from ast import literal_eval from ast import literal_eval
from collections.abc import Iterator from collections.abc import Iterator, Mapping
from itertools import chain, islice from itertools import chain, islice
import logging
import math import math
import re import re
from types import GeneratorType from types import GeneratorType
@@ -9,16 +8,17 @@ from typing import Any
import jinja2 as jinja import jinja2 as jinja
from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate
from jinja2.runtime import missing as Missing
from esphome.yaml_util import ESPLiteralValue
TemplateError = jinja.TemplateError TemplateError = jinja.TemplateError
TemplateSyntaxError = jinja.TemplateSyntaxError TemplateSyntaxError = jinja.TemplateSyntaxError
TemplateRuntimeError = jinja.TemplateRuntimeError TemplateRuntimeError = jinja.TemplateRuntimeError
UndefinedError = jinja.UndefinedError UndefinedError = jinja.UndefinedError
Undefined = jinja.Undefined Undefined = jinja.Undefined
# Sentinel key for resolver callback in ContextVars.
# Dots are invalid in substitution names so this can never collide with user keys.
Resolver = ".resolver"
_LOGGER = logging.getLogger(__name__)
DETECT_JINJA = r"(\$\{)" DETECT_JINJA = r"(\$\{)"
detect_jinja_re = re.compile( detect_jinja_re = re.compile(
@@ -52,33 +52,6 @@ SAFE_GLOBALS = {
} }
class JinjaStr(str):
"""
Wraps a string containing an unresolved Jinja expression,
storing the variables visible to it when it failed to resolve.
For example, an expression inside a package, `${ A * B }` may fail
to resolve at package parsing time if `A` is a local package var
but `B` is a substitution defined in the root yaml.
Therefore, we store the value of `A` as an upvalue bound
to the original string so we may be able to resolve `${ A * B }`
later in the main substitutions pass.
"""
Undefined = object()
def __new__(cls, value: str, upvalues=None):
if isinstance(value, JinjaStr):
base = str(value)
merged = {**value.upvalues, **(upvalues or {})}
else:
base = value
merged = dict(upvalues or {})
obj = super().__new__(cls, base)
obj.upvalues = merged
obj.result = JinjaStr.Undefined
return obj
class JinjaError(Exception): class JinjaError(Exception):
def __init__(self, context_trace: dict, expr: str): def __init__(self, context_trace: dict, expr: str):
self.context_trace = context_trace self.context_trace = context_trace
@@ -106,9 +79,13 @@ class JinjaError(Exception):
class TrackerContext(jinja.runtime.Context): class TrackerContext(jinja.runtime.Context):
def resolve_or_missing(self, key): def resolve_or_missing(self, key):
val = super().resolve_or_missing(key) val = super().resolve_or_missing(key)
if isinstance(val, JinjaStr): if val is Missing:
self.environment.context_trace[key] = val # Variable not in the template context — check if a resolver callback
val, _ = self.environment.expand(val) # was registered (by _push_context) to lazily resolve dependencies
# between substitution variables in the same block.
resolver = super().resolve_or_missing(Resolver)
if resolver is not Missing:
val = resolver(key)
self.environment.context_trace[key] = val self.environment.context_trace[key] = val
return val return val
@@ -160,15 +137,13 @@ def _concat_nodes_override(values: Iterator[Any]) -> Any:
class Jinja(jinja.Environment): class Jinja(jinja.Environment):
""" """Jinja environment configured for ESPHome substitution expressions."""
Wraps a Jinja environment
"""
# jinja environment customization overrides # jinja environment customization overrides
code_generator_class = NativeCodeGenerator code_generator_class = NativeCodeGenerator
concat = staticmethod(_concat_nodes_override) concat = staticmethod(_concat_nodes_override)
def __init__(self, context_vars: dict): def __init__(self) -> None:
super().__init__( super().__init__(
trim_blocks=True, trim_blocks=True,
lstrip_blocks=True, lstrip_blocks=True,
@@ -183,49 +158,25 @@ class Jinja(jinja.Environment):
self.context_class = TrackerContext self.context_class = TrackerContext
self.add_extension("jinja2.ext.do") self.add_extension("jinja2.ext.do")
self.context_trace = {} self.context_trace = {}
self.context_vars = {**context_vars}
for k, v in self.context_vars.items():
if isinstance(v, ESPLiteralValue):
continue
if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v):
self.context_vars[k] = JinjaStr(v, self.context_vars)
self.globals = { self.globals = {**self.globals, **SAFE_GLOBALS}
**self.globals,
**self.context_vars,
**SAFE_GLOBALS,
}
def expand(self, content_str: str | JinjaStr) -> Any: def expand(self, content_str: str, context_vars: Mapping[str, Any]) -> Any:
""" """
Renders a string that may contain Jinja expressions or statements Renders a string that may contain Jinja expressions or statements
Returns the resulting value if all variables and expressions could be resolved. Returns the resulting value if all variables and expressions could be resolved.
Otherwise, it returns a tagged (JinjaStr) string that captures variables
in scope (upvalues), like a closure for later evaluation.
""" """
result = None result = None
override_vars = {}
if isinstance(content_str, JinjaStr):
if content_str.result is not JinjaStr.Undefined:
return content_str.result, None
# If `value` is already a JinjaStr, it means we are trying to evaluate it again
# in a parent pass.
# Hopefully, all required variables are visible now.
override_vars = content_str.upvalues
old_trace = self.context_trace old_trace = self.context_trace
self.context_trace = {} self.context_trace = {}
try: try:
template = self.from_string(content_str) template = self.from_string(content_str)
result = template.render(override_vars) result = template.render(context_vars)
if isinstance(result, Undefined): if isinstance(result, Undefined):
print("" + result) # force a UndefinedError exception str(result) # force a UndefinedError exception
except (TemplateSyntaxError, UndefinedError) as err: except UndefinedError as err:
# `content_str` contains a Jinja expression that refers to a variable that is undefined raise err
# in this scope. Perhaps it refers to a root substitution that is not visible yet.
# Therefore, return `content_str` as a JinjaStr, which contains the variables
# that are actually visible to it at this point to postpone evaluation.
return JinjaStr(content_str, {**self.context_vars, **override_vars}), err
except JinjaError as err: except JinjaError as err:
err.context_trace = {**self.context_trace, **err.context_trace} err.context_trace = {**self.context_trace, **err.context_trace}
err.eval_stack.append(content_str) err.eval_stack.append(content_str)
@@ -242,10 +193,7 @@ class Jinja(jinja.Environment):
finally: finally:
self.context_trace = old_trace self.context_trace = old_trace
if isinstance(content_str, JinjaStr): return result
content_str.result = result
return result, None
class JinjaTemplate(NativeTemplate): class JinjaTemplate(NativeTemplate):
+16 -14
View File
@@ -12,7 +12,8 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from esphome import core, loader, pins, yaml_util from esphome import core, loader, pins, yaml_util
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered from esphome.components.substitutions import do_substitution_pass
from esphome.config_helpers import Extend, Remove, merge_config
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ESPHOME, CONF_ESPHOME,
@@ -974,7 +975,7 @@ class PinUseValidationCheck(ConfigValidationStep):
def validate_config( def validate_config(
config: dict[str, Any], config: dict[str, Any],
command_line_substitutions: dict[str, Any], command_line_substitutions: dict[str, Any] | None,
skip_external_update: bool = False, skip_external_update: bool = False,
) -> Config: ) -> Config:
result = Config() result = Config()
@@ -994,21 +995,15 @@ def validate_config(
result.add_error(err) result.add_error(err)
return result return result
CORE.raw_config = config
# 1. Load substitutions # 1. Load substitutions
if CONF_SUBSTITUTIONS in config or command_line_substitutions: if CONF_SUBSTITUTIONS in config or command_line_substitutions:
from esphome.components import substitutions
result[CONF_SUBSTITUTIONS] = merge_dicts_ordered(
config.get(CONF_SUBSTITUTIONS) or {}, command_line_substitutions
)
result.add_output_path([CONF_SUBSTITUTIONS], CONF_SUBSTITUTIONS) result.add_output_path([CONF_SUBSTITUTIONS], CONF_SUBSTITUTIONS)
try: try:
substitutions.do_substitution_pass(config, command_line_substitutions) config = do_substitution_pass(config, command_line_substitutions)
except vol.Invalid as err: except vol.Invalid as err:
result.add_error(err) CORE.raw_config = config
return result result.add_error(err)
return result
# 1.1. Merge packages # 1.1. Merge packages
if CONF_PACKAGES in config: if CONF_PACKAGES in config:
@@ -1016,6 +1011,9 @@ def validate_config(
config = merge_packages(config) config = merge_packages(config)
# Remove substitutions from config during validation to prevent
# re-substitution. Re-added to result at the end of this function.
substitutions = config.pop(CONF_SUBSTITUTIONS, None)
CORE.raw_config = config CORE.raw_config = config
# 1.2. Resolve !extend and !remove and check for REPLACEME # 1.2. Resolve !extend and !remove and check for REPLACEME
@@ -1089,6 +1087,10 @@ def validate_config(
result.run_validation_steps() result.run_validation_steps()
if substitutions is not None:
result[CONF_SUBSTITUTIONS] = substitutions
result.move_to_end(CONF_SUBSTITUTIONS, last=False)
return result return result
+2 -39
View File
@@ -325,9 +325,7 @@ class ESPHomeLoaderMixin:
return val return val
@_add_data_ref @_add_data_ref
def construct_include( def construct_include(self, node: yaml.Node) -> Any:
self, node: yaml.Node
) -> dict[str, Any] | OrderedDict[str, Any]:
from esphome.const import CONF_VARS from esphome.const import CONF_VARS
def extract_file_vars(node): def extract_file_vars(node):
@@ -344,9 +342,7 @@ class ESPHomeLoaderMixin:
file, vars = node.value, None file, vars = node.value, None
result = self.yaml_loader(self._rel_path(file)) result = self.yaml_loader(self._rel_path(file))
if not vars: return add_context(result, vars)
vars = {}
return substitute_vars(result, vars)
@_add_data_ref @_add_data_ref
def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]:
@@ -495,39 +491,6 @@ def parse_yaml(
) )
def substitute_vars(config, vars):
from esphome.components import substitutions
from esphome.const import CONF_SUBSTITUTIONS
org_subs = None
result = config
if not isinstance(config, dict):
# when the included yaml contains a list or a scalar
# wrap it into an OrderedDict because do_substitution_pass expects it
result = OrderedDict([("yaml", config)])
elif CONF_SUBSTITUTIONS in result:
org_subs = result.pop(CONF_SUBSTITUTIONS)
defaults = {}
if CONF_DEFAULTS in result:
defaults = result.pop(CONF_DEFAULTS)
result[CONF_SUBSTITUTIONS] = vars
for k, v in defaults.items():
if k not in result[CONF_SUBSTITUTIONS]:
result[CONF_SUBSTITUTIONS][k] = v
# Ignore missing vars that refer to the top level substitutions
substitutions.do_substitution_pass(result, None, ignore_missing=True)
result.pop(CONF_SUBSTITUTIONS)
if not isinstance(config, dict):
result = result["yaml"] # unwrap the result
elif org_subs:
result[CONF_SUBSTITUTIONS] = org_subs
return result
def _load_yaml_internal_with_type( def _load_yaml_internal_with_type(
loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader],
fname: Path, fname: Path,
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
from esphome.components.substitutions import do_substitution_pass
import esphome.config as config_module import esphome.config as config_module
from esphome.config import resolve_extend_remove from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove from esphome.config_helpers import Extend, Remove
@@ -71,6 +72,7 @@ def fixture_basic_esphome():
def packages_pass(config): def packages_pass(config):
"""Wrapper around packages_pass that also resolves Extend and Remove.""" """Wrapper around packages_pass that also resolves Extend and Remove."""
config = do_packages_pass(config) config = do_packages_pass(config)
config = do_substitution_pass(config)
config = merge_packages(config) config = merge_packages(config)
resolve_extend_remove(config) resolve_extend_remove(config)
return config return config
@@ -38,3 +38,20 @@ test_list:
- '{ 79, 82 }' - '{ 79, 82 }'
- a: 15 should be 15, overridden from command line - a: 15 should be 15, overridden from command line
b: 20 should stay as 20, not overridden b: 20 should stay as 20, not overridden
- aa:
- 1
- 2
- 3
- 4
- 5
- 6
bb:
- 7
- 8
- 9
- aa:
x: 1
y: 3
z: 4
bb:
w: 5
@@ -44,3 +44,13 @@ test_list:
- '{ ${position.x}, ${position.y} }' - '{ ${position.x}, ${position.y} }'
- a: ${a} should be 15, overridden from command line - a: ${a} should be 15, overridden from command line
b: ${b} should stay as 20, not overridden b: ${b} should stay as 20, not overridden
# Test merging lists when substituted keys resolve to an existing key
- ${ "aa" }: [1, 2, 3]
${ "a" + "a" }: [4, 5, 6]
${ "bb" }: [7, 8, 9]
# Test merging dicts when substituted keys resolve to an existing key
- ${ "aa" }: {"x": 1, "y": 2}
${ "a" + "a" }: {"y": 3, "z": 4}
${ "bb" }: {"w": 5}
@@ -9,6 +9,11 @@ substitutions:
numberOne: 1 numberOne: 1
var1: 79 var1: 79
double_width: 14 double_width: 14
double_height: 16
y: ${x}
x: ${y}
b: 79
c: 80
test_list: test_list:
- The area is 56 - The area is 56
- 56 - 56
@@ -27,3 +32,4 @@ test_list:
- chr(97) = a - chr(97) = a
- len([1,2,3]) = 3 - len([1,2,3]) = 3
- width = 7, double_width = 14 - width = 7, double_width = 14
- a = ${a}
@@ -1,4 +1,7 @@
substitutions: substitutions:
y: ${x} # Circular reference, expect to pass unresolved.
x: ${y} # Circular reference, expect to pass unresolved.
double_height: ${height * 2}
width: 7 width: 7
height: 8 height: 8
enabled: true enabled: true
@@ -9,6 +12,8 @@ substitutions:
numberOne: 1 numberOne: 1
var1: 79 var1: 79
double_width: ${width * 2} double_width: ${width * 2}
c: ${b+1}
b: ${undefined_variable | default(79) }
test_list: test_list:
- "The area is ${width * height}" - "The area is ${width * height}"
@@ -25,3 +30,4 @@ test_list:
- chr(97) = ${ chr(97) } - chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) } - len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width} - width = ${width}, double_width = ${double_width}
- a = ${a}
@@ -0,0 +1,46 @@
fancy_component: &id001
- id: component9
value: 9
some_component:
- id: component1
value: 1
- id: component2
value: 2
- id: component3
value: 3
- id: component4
value: 4
- id: component5
value: 79
power: 200
- id: component6
value: 6
- id: component7
value: 7
switch: &id002
- platform: gpio
id: switch1
pin: 12
- platform: gpio
id: switch2
pin: 13
display:
- platform: ili9xxx
dimensions:
width: 100
height: 480
substitutions:
extended_component: component5
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
substitutions:
fancy_subst: 42
fancy_component: *id001
pin: 12
some_switches: *id002
package_selection: fancy_package
fancy_subst: 42
@@ -0,0 +1,63 @@
substitutions:
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
substitutions:
fancy_subst: 42
fancy_component:
- id: component9
value: 9
pin: 12
some_switches:
- platform: gpio
id: switch1
pin: ${pin}
- platform: gpio
id: switch2
pin: ${pin+1}
package_selection: fancy_package
packages:
- ${ package_options[package_selection] }
- some_component:
- id: component1
value: 1
- some_component:
- id: component2
value: 2
- switch: ${ some_switches }
- packages:
package_with_defaults: !include
file: display.yaml
vars:
native_width: 100
high_dpi: false
my_package:
packages:
- packages:
special_package:
substitutions:
extended_component: component5
some_component:
- id: component3
value: 3
some_component:
- id: component4
value: 4
- id: !extend ${ extended_component }
power: 200
value: 79
some_component:
- id: component5
value: 5
some_component:
- id: component6
value: 6
- id: component7
value: 7
@@ -0,0 +1,5 @@
values:
- var1: $var1
- a: 10
- b: B-default
- c: The value of C is 79
@@ -0,0 +1,7 @@
# Test that include_vars with vars works even when there are no substitutions key defined.
packages:
- !include
file: inc1.yaml
vars:
a: 10
c: 79
+227 -17
View File
@@ -10,9 +10,10 @@ from esphome import config as config_module, yaml_util
from esphome.components import substitutions from esphome.components import substitutions
from esphome.components.packages import do_packages_pass, merge_packages from esphome.components.packages import do_packages_pass, merge_packages
from esphome.config import resolve_extend_remove from esphome.config import resolve_extend_remove
from esphome.config_helpers import merge_config from esphome.config_helpers import Extend, merge_config
import esphome.config_validation as cv
from esphome.const import CONF_SUBSTITUTIONS from esphome.const import CONF_SUBSTITUTIONS
from esphome.core import CORE from esphome.core import CORE, Lambda
from esphome.util import OrderedDict from esphome.util import OrderedDict
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -144,7 +145,7 @@ def test_substitutions_fixtures(
config = do_packages_pass(config) config = do_packages_pass(config)
substitutions.do_substitution_pass(config, command_line_substitutions) config = substitutions.do_substitution_pass(config, command_line_substitutions)
config = merge_packages(config) config = merge_packages(config)
@@ -206,7 +207,7 @@ def test_substitutions_with_command_line_maintains_ordered_dict() -> None:
command_line_subs = {"var2": "override", "var3": "new_value"} command_line_subs = {"var2": "override", "var3": "new_value"}
# Call do_substitution_pass with command line substitutions # Call do_substitution_pass with command line substitutions
substitutions.do_substitution_pass(config, command_line_subs) config = substitutions.do_substitution_pass(config, command_line_subs)
# Verify that config is still an OrderedDict # Verify that config is still an OrderedDict
assert isinstance(config, OrderedDict), "Config should remain an OrderedDict" assert isinstance(config, OrderedDict), "Config should remain an OrderedDict"
@@ -234,7 +235,7 @@ def test_substitutions_without_command_line_maintains_ordered_dict() -> None:
config["other_key"] = "other_value" config["other_key"] = "other_value"
# Call without command line substitutions # Call without command line substitutions
substitutions.do_substitution_pass(config, None) config = substitutions.do_substitution_pass(config, None)
# Verify that config is still an OrderedDict # Verify that config is still an OrderedDict
assert isinstance(config, OrderedDict), "Config should remain an OrderedDict" assert isinstance(config, OrderedDict), "Config should remain an OrderedDict"
@@ -268,7 +269,7 @@ def test_substitutions_after_merge_config_maintains_ordered_dict() -> None:
) )
# Now try to run substitution pass on the merged config # Now try to run substitution pass on the merged config
substitutions.do_substitution_pass(merged_config, None) merged_config = substitutions.do_substitution_pass(merged_config, None)
# Should not raise AttributeError # Should not raise AttributeError
assert isinstance(merged_config, OrderedDict), ( assert isinstance(merged_config, OrderedDict), (
@@ -279,7 +280,7 @@ def test_substitutions_after_merge_config_maintains_ordered_dict() -> None:
def test_validate_config_with_command_line_substitutions_maintains_ordered_dict( def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
tmp_path, tmp_path: Path,
) -> None: ) -> None:
"""Test that validate_config preserves OrderedDict when merging command-line substitutions. """Test that validate_config preserves OrderedDict when merging command-line substitutions.
@@ -288,7 +289,7 @@ def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
""" """
# Create a minimal valid config # Create a minimal valid config
test_config = OrderedDict() test_config = OrderedDict()
test_config["esphome"] = {"name": "test_device", "platform": "ESP32"} test_config["esphome"] = {"name": "test_device"}
test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"}) test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"})
test_config["esp32"] = {"board": "esp32dev"} test_config["esp32"] = {"board": "esp32dev"}
@@ -314,17 +315,11 @@ def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
assert result[CONF_SUBSTITUTIONS]["var3"] == "new_value" assert result[CONF_SUBSTITUTIONS]["var3"] == "new_value"
def test_validate_config_without_command_line_substitutions_maintains_ordered_dict( def _get_test_minimal_valid_config(tmp_path: Path) -> OrderedDict:
tmp_path, """Helper to create a minimal valid config for testing."""
) -> None:
"""Test that validate_config preserves OrderedDict without command-line substitutions.
This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set
using merge_dicts_ordered() when command_line_substitutions is None.
"""
# Create a minimal valid config # Create a minimal valid config
test_config = OrderedDict() test_config = OrderedDict()
test_config["esphome"] = {"name": "test_device", "platform": "ESP32"} test_config["esphome"] = {"name": "test_device"}
test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"}) test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"})
test_config["esp32"] = {"board": "esp32dev"} test_config["esp32"] = {"board": "esp32dev"}
@@ -332,6 +327,19 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di
test_yaml = tmp_path / "test.yaml" test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config") test_yaml.write_text("# test config")
CORE.config_path = test_yaml CORE.config_path = test_yaml
return test_config
def test_validate_config_without_command_line_substitutions_maintains_ordered_dict(
tmp_path: Path,
) -> None:
"""Test that validate_config preserves OrderedDict without command-line substitutions.
This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set
using merge_dicts_ordered() when command_line_substitutions is None.
"""
test_config = _get_test_minimal_valid_config(tmp_path)
# Call validate_config without command line substitutions # Call validate_config without command line substitutions
result = config_module.validate_config(test_config, None) result = config_module.validate_config(test_config, None)
@@ -384,3 +392,205 @@ def test_merge_config_preserves_ordered_dict() -> None:
assert not isinstance(result, OrderedDict), ( assert not isinstance(result, OrderedDict), (
"dict + dict should not return OrderedDict" "dict + dict should not return OrderedDict"
) )
def test_substitution_pass_error_gets_captured(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""vol.Invalid from do_substitution_pass is captured by validate_config."""
# Patch the target: in config_module.do_substitution_pass (NOT where it's defined)
def fake_do_substitution_pass(*args, **kwargs):
raise cv.Invalid("Error in do_substitutions_pass!!")
monkeypatch.setattr(
config_module, "do_substitution_pass", fake_do_substitution_pass
)
# Prepare minimal config + no CLI substitutions
config = _get_test_minimal_valid_config(tmp_path)
# Call the function under test
result = config_module.validate_config(config, None)
# Now assert that add_error was called with the vol.Invalid
assert "Error in do_substitutions_pass!!" in str(result.get_error_for_path([]))
@pytest.mark.parametrize(
"value", ["", " ", "1foo", "9VAR", "0abc", "$1foo", "$9VAR", "$0abc"]
)
def test_validate_substitution_key_empty_raises(value: str) -> None:
"""Empty (or all-whitespace) substitution keys are rejected."""
with pytest.raises(cv.Invalid):
substitutions.validate_substitution_key(value)
@pytest.mark.parametrize(
"input_value, expected_output",
[
("$FOO_bar9", "FOO_bar9"), # Valid key with leading '$'
("Foo_bar9", "Foo_bar9"), # Normal valid key
],
)
def test_validate_substitution_key_valid(
input_value: str, expected_output: str
) -> None:
"""Valid substitution keys are accepted with optional leading '$'."""
result = substitutions.validate_substitution_key(input_value)
assert result == expected_output
def test_circular_dependency_warnings(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Circular substitution references produce warnings naming the cause."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"x": "${y}", "y": "${x}"}),
"key": "value",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'x'" in caplog.text
assert "'y' is undefined" in caplog.text
assert "Could not resolve substitution variable 'y'" in caplog.text
assert "'x' is undefined" in caplog.text
# Verify path includes location
assert "substitutions->x" in caplog.text
assert "substitutions->y" in caplog.text
def test_missing_dependency_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A substitution referencing an undefined variable warns with the cause."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"a": "${missing}"}),
"key": "value",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'a'" in caplog.text
assert "'missing' is undefined" in caplog.text
assert "substitutions->a" in caplog.text
def test_undefined_variable_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A reference to an undefined variable in config values produces a warning."""
config = OrderedDict(
{
"key": "${undefined_var}",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "'undefined_var' is undefined" in caplog.text
def test_password_field_warnings_suppressed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Undefined variables in password fields should not produce warnings."""
config = OrderedDict(
{
"password": "${undefined_var}",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert caplog.text == ""
def test_config_context_unresolvable_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Unresolvable vars in a ConfigContext produce warnings via push_context."""
inner = OrderedDict({"key": "${a}"})
yaml_util.add_context(inner, {"a": "${undefined}"})
config = OrderedDict({"items": [inner]})
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'a'" in caplog.text
assert "'undefined' is undefined" in caplog.text
def test_non_string_substitution_value_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Undefined vars in non-string contexts (e.g. dict keys) produce warnings."""
config = OrderedDict(
{
"items": {"${undefined_key}": "value"},
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "'undefined_key' is undefined" in caplog.text
def test_lambda_substitution() -> None:
"""Substitution inside a Lambda value should be expanded."""
lam = Lambda("return ${var};")
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"var": "42"}),
"lambda": lam,
}
)
substitutions.do_substitution_pass(config)
assert lam.value == "return 42;"
def test_lambda_no_substitution_unchanged() -> None:
"""A Lambda with no variable references should not be mutated."""
lam = Lambda("return 1;")
original_value = lam.value
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"var": "42"}),
"lambda": lam,
}
)
substitutions.do_substitution_pass(config)
assert lam.value is original_value
def test_extend_substitution() -> None:
"""Substitution inside an Extend value should be expanded."""
ext = Extend("${component_id}")
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"component_id": "my_sensor"}),
"sensor": ext,
}
)
substitutions.do_substitution_pass(config)
assert ext.value == "my_sensor"
def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> None:
"""Non-mapping substitutions raises cv.Invalid."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: ["not", "a", "mapping"],
"other": "value",
}
)
with pytest.raises(
cv.Invalid, match="Substitutions must be a key to value mapping"
):
substitutions.do_substitution_pass(config)
+1 -1
View File
@@ -25,7 +25,7 @@ def test_include_with_vars(fixture_path: Path) -> None:
yaml_file = fixture_path / "yaml_util" / "includetest.yaml" yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
actual = yaml_util.load_yaml(yaml_file) actual = yaml_util.load_yaml(yaml_file)
substitutions.do_substitution_pass(actual, None) actual = substitutions.do_substitution_pass(actual, None)
assert actual["esphome"]["name"] == "original" assert actual["esphome"]["name"] == "original"
assert actual["esphome"]["libraries"][0] == "Wire" assert actual["esphome"]["libraries"][0] == "Wire"
assert actual["esp8266"]["board"] == "nodemcu" assert actual["esp8266"]["board"] == "nodemcu"