[packages] Fix premature substitution of vars in remote package files (#15997)
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
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

Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Javier Peletier
2026-04-25 19:06:58 +02:00
committed by GitHub
parent a437b3086b
commit b5ccd55f4e
4 changed files with 219 additions and 5 deletions
+18 -3
View File
@@ -378,9 +378,8 @@ def _substitute_package_definition(
Local package contents are left untouched — they will be substituted Local package contents are left untouched — they will be substituted
later during the main substitution pass. later during the main substitution pass.
""" """
if isinstance(package_config, str) or (
isinstance(package_config, dict) and is_remote_package(package_config) def do_substitute(package_config: dict | str) -> dict | str:
):
# Collect undefined-variable errors (rather than raising strict) so the # Collect undefined-variable errors (rather than raising strict) so the
# path walked through a remote-package dict is preserved and the user # path walked through a remote-package dict is preserved and the user
# sees which field (url / path / ref / ...) referenced the undefined # sees which field (url / path / ref / ...) referenced the undefined
@@ -394,6 +393,22 @@ def _substitute_package_definition(
errors=errors, errors=errors,
) )
raise_first_undefined(errors, "package definition") raise_first_undefined(errors, "package definition")
return package_config
if isinstance(package_config, str):
return do_substitute(package_config)
if isinstance(package_config, dict) and is_remote_package(package_config):
# Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be
# passed as-is to the package YAML and may contain their own substitution expressions that should not
# be prematurely evaluated here.
if CONF_FILES in package_config:
for file_def in package_config[CONF_FILES]:
if isinstance(file_def, dict) and CONF_VARS in file_def:
file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS])
package_config = do_substitute(package_config)
return package_config return package_config
+10 -1
View File
@@ -113,6 +113,15 @@ def make_data_base(
return value return value
def make_literal(value: Any) -> ESPLiteralValue | Any:
"""Wrap a value in an ESPLiteralValue object."""
try:
return add_class_to_obj(value, ESPLiteralValue)
except TypeError:
# Adding class failed, ignore error
return value
def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
"""Tags a list/string/dict value with context vars that must be applied to it and its children """Tags a list/string/dict value with context vars that must be applied to it and its children
during the substitution pass. If no vars are given, no tagging is done. during the substitution pass. If no vars are given, no tagging is done.
@@ -525,7 +534,7 @@ class ESPHomeLoaderMixin:
obj = self.construct_sequence(node) obj = self.construct_sequence(node)
elif isinstance(node, yaml.MappingNode): elif isinstance(node, yaml.MappingNode):
obj = self.construct_mapping(node) obj = self.construct_mapping(node)
return add_class_to_obj(obj, ESPLiteralValue) return make_literal(obj)
@_add_data_ref @_add_data_ref
def construct_extend(self, node: yaml.Node) -> Extend: def construct_extend(self, node: yaml.Node) -> Extend:
@@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path)
line, col = int(match.group(1)), int(match.group(2)) line, col = int(match.group(1)), int(match.group(2))
assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})"
assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})"
def test_substitute_package_definition_vars_preserved_literally() -> None:
"""``vars:`` blocks in remote-package files are not substituted prematurely.
Variable references inside ``vars:`` may resolve to substitutions
contributed by sibling packages that have not yet been loaded, so they
must be passed through untouched and resolved later by the package YAML.
"""
pkg = {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_FILES: [
{
CONF_PATH: "common/somefile.yaml",
CONF_VARS: {"pin": "${PIN}"},
},
],
}
# Note: PIN is intentionally NOT in the context — it is meant to
# be resolved later, when the package YAML is processed.
result = _substitute_package_definition(pkg, ContextVars())
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
def test_substitute_package_definition_other_fields_still_substituted() -> None:
"""Marking ``vars:`` literal does not stop substitution of url/ref/path."""
ctx = ContextVars({"branch": "release", "org": "esphome"})
pkg = {
CONF_URL: "https://github.com/${org}/firmware",
CONF_REF: "${branch}",
CONF_FILES: [
{
CONF_PATH: "common/sensor.yaml",
CONF_VARS: {"pin": "${PIN}"},
},
],
}
result = _substitute_package_definition(pkg, ctx)
assert result[CONF_URL] == "https://github.com/esphome/firmware"
assert result[CONF_REF] == "release"
# vars passed through unchanged
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
def test_substitute_package_definition_without_vars_unaffected() -> None:
"""Files entries without a ``vars:`` block continue to work."""
ctx = ContextVars({"branch": "main"})
pkg = {
CONF_URL: "https://github.com/esphome/firmware",
CONF_REF: "${branch}",
CONF_FILES: [
{CONF_PATH: "file1.yaml"},
"file2.yaml",
],
}
result = _substitute_package_definition(pkg, ctx)
assert result[CONF_REF] == "main"
assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"}
assert result[CONF_FILES][1] == "file2.yaml"
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_package_vars_resolved_against_sibling_package_substitutions(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""A ``vars:`` reference in one remote package can resolve to a
substitution defined in a sibling remote package.
A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a
lower-priority package's ``files: -> vars:`` references that substitution.
Because packages are processed highest-priority first and ``vars:`` is now
preserved literally during package-definition processing, the substitution
is resolved correctly when the package YAML itself is loaded.
"""
mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock())
mock_is_file.return_value = True
# Two YAML files mocked from the "remote" repo:
# - platform.yaml exports a substitution ``SENSOR_PIN``
# - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to
# ``${SENSOR_PIN}`` and resolved against the merged substitutions).
mock_load_yaml.side_effect = [
# Order matches reverse-priority traversal (highest priority first).
OrderedDict(
{
CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"},
}
),
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
"pin": "${pin}",
}
],
}
),
]
config = {
CONF_PACKAGES: {
"special_sensor": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_FILES: [
{
CONF_PATH: "sensor.yaml",
CONF_VARS: {"pin": "${SENSOR_PIN}"},
},
],
CONF_REFRESH: "1d",
},
"platform": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_FILES: ["platform.yaml"],
CONF_REFRESH: "1d",
},
}
}
actual = packages_pass(config)
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
+61 -1
View File
@@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.core import DocumentLocation, DocumentRange, EsphomeError
from esphome.util import OrderedDict from esphome.util import OrderedDict
from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base from esphome.yaml_util import (
ESPHomeDataBase,
ESPLiteralValue,
format_path,
make_data_base,
make_literal,
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj():
obj = _located("${var}", "main.yaml", 0, 0) obj = _located("${var}", "main.yaml", 0, 0)
result = format_path([], obj) result = format_path([], obj)
assert result == "In: in main.yaml 1:1" assert result == "In: in main.yaml 1:1"
def test_make_literal_wraps_dict() -> None:
"""A dict is wrapped so it becomes an ESPLiteralValue instance."""
value = {"key": "${var}"}
result = make_literal(value)
assert isinstance(result, ESPLiteralValue)
assert isinstance(result, dict)
assert result == {"key": "${var}"}
def test_make_literal_wraps_list() -> None:
"""A list is wrapped so it becomes an ESPLiteralValue instance."""
value = ["${var}", "plain"]
result = make_literal(value)
assert isinstance(result, ESPLiteralValue)
assert isinstance(result, list)
assert result == ["${var}", "plain"]
def test_make_literal_wraps_string() -> None:
"""A string is wrapped so it becomes an ESPLiteralValue instance."""
result = make_literal("${var}")
assert isinstance(result, ESPLiteralValue)
assert result == "${var}"
def test_make_literal_returns_already_wrapped_value_unchanged() -> None:
"""Wrapping a value that is already an ESPLiteralValue returns it as-is."""
value = make_literal({"key": "value"})
assert isinstance(value, ESPLiteralValue)
result = make_literal(value)
assert result is value
def test_make_literal_returns_none_unchanged() -> None:
"""Values whose class cannot be augmented (e.g. ``None``) are returned as-is."""
result = make_literal(None)
assert result is None
def test_make_literal_blocks_substitution() -> None:
"""A value wrapped with make_literal is skipped by the substitution pass."""
value = make_literal({"pin": "${PIN}"})
result = substitutions.substitute(
value,
path=[],
parent_context=substitutions.ContextVars(),
strict_undefined=False,
)
# The literal block must remain untouched, even though the variable is
# undefined in the context.
assert result == {"pin": "${PIN}"}
assert isinstance(result, ESPLiteralValue)