[substitutions] speed up config loading: substitutions pass and !include redesign (package refactor part 4) (#12126)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Javier Peletier
2026-03-24 10:57:22 +01:00
committed by GitHub
parent 793813790a
commit 7eddf429ea
17 changed files with 781 additions and 168 deletions

View File

@@ -69,7 +69,7 @@ def test_packages_skip_update_false(
}
# Call with skip_update=False (default)
do_packages_pass(config, skip_update=False)
do_packages_pass(config, command_line_substitutions={}, skip_update=False)
# Verify clone_or_update was called with actual refresh value
mock_clone_or_update.assert_called_once()
@@ -104,7 +104,7 @@ def test_packages_default_no_skip(
}
# Call without skip_update parameter
do_packages_pass(config)
do_packages_pass(config, command_line_substitutions={})
# Verify clone_or_update was called with actual refresh value
mock_clone_or_update.assert_called_once()

View File

@@ -37,6 +37,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.util import OrderedDict
from esphome.yaml_util import add_context
# Test strings
TEST_DEVICE_NAME = "test_device_name"
@@ -70,7 +71,7 @@ def fixture_basic_esphome():
def packages_pass(config):
"""Wrapper around packages_pass that also resolves Extend and Remove."""
"""Passes the config through the packages processing steps."""
config = do_packages_pass(config)
config = do_substitution_pass(config)
config = merge_packages(config)
@@ -705,6 +706,85 @@ def test_remote_packages_with_files_list(
assert actual == expected
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_with_files_list_and_substitutions(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""
Ensures that packages are loaded as mixed list of dictionary and strings
"""
# Mock the response from git.clone_or_update
mock_revert = MagicMock()
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
# Mock the response from pathlib.Path.is_file
mock_is_file.return_value = True
# Mock the response from esphome.yaml_util.load_yaml
mock_load_yaml.side_effect = [
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
}
]
}
),
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
}
]
}
),
]
# Define the input config
config = {
CONF_PACKAGES: {
"package1": add_context(
{
CONF_URL: r"${url}",
CONF_REF: r"${branch}",
CONF_FILES: [
{CONF_PATH: r"$file"},
"sensor2.yaml",
],
CONF_REFRESH: "1d",
},
{
"branch": "main",
"file": TEST_YAML_FILENAME,
"url": "https://github.com/esphome/non-existant-repo",
},
)
}
}
expected = {
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
@@ -906,7 +986,7 @@ def test_packages_merge_substitutions() -> None:
},
}
actual = do_packages_pass(config)
actual = do_packages_pass(config, command_line_substitutions={})
assert actual == expected
@@ -970,33 +1050,107 @@ def test_package_merge() -> None:
assert actual == expected
def test_packages_invalid_type_raises() -> None:
"""Packages that are not a dict or list raise cv.Invalid."""
config = {
CONF_PACKAGES: "not_a_dict_or_list",
}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
):
do_packages_pass(config)
@pytest.mark.parametrize(
"invalid_package",
[
6,
"some string",
["some string"],
None,
True,
{"some_component": 8},
{3: 2},
{"some_component": r"${unevaluated expression}"},
],
)
def test_package_merge_invalid(invalid_package) -> None:
"""
Tests that trying to merge an invalid package raises an error.
"""
def test_invalid_package_contents_rejected(invalid_package: object) -> None:
"""Invalid package contents are rejected by PACKAGE_SCHEMA during do_packages_pass."""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
@pytest.mark.xfail(
reason="Deprecated single-package fallback swallows these errors. "
"Remove xfail when single-package deprecation is removed (2026.7.0).",
strict=True,
)
@pytest.mark.parametrize(
"invalid_package",
[
None,
["some string"],
{"some_component": 8},
{3: 2},
],
)
def test_invalid_package_contents_masked_by_deprecation(
invalid_package: object,
) -> None:
"""These invalid packages are swallowed by the deprecated single-package fallback."""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
def test_merge_packages_invalid_nested_type_raises() -> None:
"""Invalid nested packages type during merge raises cv.Invalid."""
config = {
CONF_PACKAGES: {
"pkg": {
CONF_PACKAGES: "invalid",
},
},
}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
):
merge_packages(config)
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_no_revert(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""Remote packages with revert=None load without retry logic."""
mock_clone_or_update.return_value = (Path("/tmp/noexists"), None)
mock_is_file.return_value = True
mock_load_yaml.return_value = OrderedDict(
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
)
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
actual = packages_pass(config)
assert actual[CONF_SENSOR] == [
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
]
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.