Files
2026-05-05 18:26:52 -05:00

1744 lines
54 KiB
Python

"""Tests for the packages component."""
import logging
from pathlib import Path
import re
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import (
CONFIG_SCHEMA,
_substitute_package_definition,
_walk_packages,
do_packages_pass,
is_package_definition,
merge_packages,
resolve_packages,
)
from esphome.components.substitutions import ContextVars, do_substitution_pass
import esphome.config as config_module
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.const import (
CONF_DEFAULTS,
CONF_DOMAIN,
CONF_ESPHOME,
CONF_FILES,
CONF_FILTERS,
CONF_ID,
CONF_MULTIPLY,
CONF_NAME,
CONF_OFFSET,
CONF_PACKAGES,
CONF_PASSWORD,
CONF_PATH,
CONF_PLATFORM,
CONF_REF,
CONF_REFRESH,
CONF_SENSOR,
CONF_SSID,
CONF_SUBSTITUTIONS,
CONF_UPDATE_INTERVAL,
CONF_URL,
CONF_VARS,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.util import OrderedDict
from esphome.yaml_util import DocumentPath, IncludeFile, add_context, load_yaml
# Test strings
TEST_DEVICE_NAME = "test_device_name"
TEST_PLATFORM = "test_platform"
TEST_WIFI_SSID = "test_wifi_ssid"
TEST_PACKAGE_WIFI_SSID = "test_package_wifi_ssid"
TEST_PACKAGE_WIFI_PASSWORD = "test_package_wifi_password"
TEST_DOMAIN = "test_domain_name"
TEST_SENSOR_PLATFORM_1 = "test_sensor_platform_1"
TEST_SENSOR_PLATFORM_2 = "test_sensor_platform_2"
TEST_SENSOR_NAME_1 = "test_sensor_name_1"
TEST_SENSOR_NAME_2 = "test_sensor_name_2"
TEST_SENSOR_NAME_3 = "test_sensor_name_3"
TEST_SENSOR_ID_1 = "test_sensor_id_1"
TEST_SENSOR_ID_2 = "test_sensor_id_2"
TEST_SENSOR_UPDATE_INTERVAL = "test_sensor_update_interval"
TEST_YAML_FILENAME = "sensor1.yaml"
@pytest.fixture(name="basic_wifi")
def fixture_basic_wifi():
return {
CONF_SSID: TEST_PACKAGE_WIFI_SSID,
CONF_PASSWORD: TEST_PACKAGE_WIFI_PASSWORD,
}
@pytest.fixture(name="basic_esphome")
def fixture_basic_esphome():
return {CONF_NAME: TEST_DEVICE_NAME, CONF_PLATFORM: TEST_PLATFORM}
def packages_pass(config):
"""Passes the config through the packages processing steps."""
config = do_packages_pass(config)
config = do_substitution_pass(config)
config = merge_packages(config)
resolve_extend_remove(config)
return config
_INCLUDE_FILE = "INCLUDE_FILE"
@pytest.mark.parametrize(
("value", "expected"),
[
# IncludeFile objects are package definitions
(_INCLUDE_FILE, True),
# Git URL shorthand strings are package definitions
("github://esphome/firmware/base.yaml@main", True),
# Remote package dicts (with url key) are package definitions
({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True),
# Plain config dicts are NOT package definitions (they are config fragments)
({"wifi": {"ssid": "test"}}, False),
# None is not a package definition
(None, False),
# Lists are not package definitions
([{"wifi": {"ssid": "test"}}], False),
# Empty dicts are not package definitions
({}, False),
],
ids=[
"include_file",
"git_shorthand",
"remote_package",
"config_fragment",
"none",
"list",
"empty_dict",
],
)
def test_is_package_definition(value: object, expected: bool) -> None:
"""Test that is_package_definition correctly identifies package definitions."""
if value is _INCLUDE_FILE:
value = MagicMock(spec=IncludeFile)
assert is_package_definition(value) is expected
def test_package_unused(basic_esphome, basic_wifi) -> None:
"""
Ensures do_package_pass does not change a config if packages aren't used.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
actual = packages_pass(config)
assert actual == config
def test_package_invalid_dict(basic_esphome, basic_wifi) -> None:
"""
If a url: key is present, it's expected to be well-formed remote package spec. Ensure an error is raised if not.
Any other simple dict passed as a package will be merged as usual but may fail later validation.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: basic_wifi | {CONF_URL: ""}}
with pytest.raises(cv.Invalid):
packages_pass(config)
@pytest.mark.parametrize(
"packages",
[
{"package1": "github://esphome/non-existant-repo/file1.yml@main"},
{"package2": "github://esphome/non-existant-repo/file1.yml"},
{"package3": "github://esphome/non-existant-repo/other-folder/file1.yml"},
[
"github://esphome/non-existant-repo/file1.yml@main",
"github://esphome/non-existant-repo/file1.yml",
"github://esphome/non-existant-repo/other-folder/file1.yml",
],
],
)
def test_package_shorthand(packages) -> None:
CONFIG_SCHEMA(packages)
@pytest.mark.parametrize(
"packages",
[
# not github
{"package1": "someplace://esphome/non-existant-repo/file1.yml@main"},
# missing repo
{"package2": "github://esphome/file1.yml"},
# missing file
{"package3": "github://esphome/non-existant-repo/@main"},
{"a": "invalid string, not shorthand"},
"some string",
3,
False,
{"a": 8},
["someplace://esphome/non-existant-repo/file1.yml@main"],
["github://esphome/file1.yml"],
["github://esphome/non-existant-repo/@main"],
["some string"],
[True],
[3],
],
)
def test_package_invalid(packages) -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(packages)
def test_package_include(basic_wifi, basic_esphome) -> None:
"""
Tests the simple case where an independent config present in a package is added to the top-level config as is.
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
"""
config = {
CONF_ESPHOME: basic_esphome,
CONF_PACKAGES: {"network": {CONF_WIFI: basic_wifi}},
}
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
actual = packages_pass(config)
assert actual == expected
def test_single_package(
basic_esphome,
basic_wifi,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Tests the simple case where a single package is added to the top-level config as is.
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
This tests the case where the user just put packages: !include package.yaml, not
part of a list or mapping of packages.
This behavior is deprecated, the test also checks if a warning is issued.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}}
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
with caplog.at_level("WARNING"):
actual = packages_pass(config)
assert actual == expected
assert "This method for including packages will go away in 2026.7.0" in caplog.text
def test_package_append(basic_wifi, basic_esphome) -> None:
"""
Tests the case where a key is present in both a package and top-level config.
In this test, CONF_WIFI is defined in a package, and CONF_DOMAIN is added to it at the top level.
"""
config = {
CONF_ESPHOME: basic_esphome,
CONF_PACKAGES: {"network": {CONF_WIFI: basic_wifi}},
CONF_WIFI: {CONF_DOMAIN: TEST_DOMAIN},
}
expected = {
CONF_ESPHOME: basic_esphome,
CONF_WIFI: {
CONF_SSID: TEST_PACKAGE_WIFI_SSID,
CONF_PASSWORD: TEST_PACKAGE_WIFI_PASSWORD,
CONF_DOMAIN: TEST_DOMAIN,
},
}
actual = packages_pass(config)
assert actual == expected
def test_package_override(basic_wifi, basic_esphome) -> None:
"""
Ensures that the top-level configuration takes precedence over duplicate keys defined in a package.
In this test, CONF_SSID should be overwritten by that defined in the top-level config.
"""
config = {
CONF_ESPHOME: basic_esphome,
CONF_PACKAGES: {"network": {CONF_WIFI: basic_wifi}},
CONF_WIFI: {CONF_SSID: TEST_WIFI_SSID},
}
expected = {
CONF_ESPHOME: basic_esphome,
CONF_WIFI: {
CONF_SSID: TEST_WIFI_SSID,
CONF_PASSWORD: TEST_PACKAGE_WIFI_PASSWORD,
},
}
actual = packages_pass(config)
assert actual == expected
def test_multiple_package_order() -> None:
"""
Ensures that mutiple packages are merged in order.
"""
config = {
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
},
}
expected = {
"logger": {
"level": "VERBOSE",
},
}
actual = packages_pass(config)
assert actual == expected
def test_package_list_merge() -> None:
"""
Ensures lists defined in both a package and the top-level config are merged correctly
"""
config = {
CONF_PACKAGES: {
"package_sensors": {
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,
},
]
}
},
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_2,
},
],
}
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,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
def test_package_list_merge_by_id() -> None:
"""
Ensures that components with matching IDs are merged correctly.
In this test, a sensor is defined in a package, and a CONF_UPDATE_INTERVAL is added at the top level,
and a sensor name is overridden in another sensor.
"""
config = {
CONF_PACKAGES: {
"package_sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
},
"package2": {
CONF_SENSOR: [
{
CONF_ID: Extend(TEST_SENSOR_ID_1),
CONF_DOMAIN: "2",
}
],
},
"package3": {
CONF_SENSOR: [
{
CONF_ID: Extend(TEST_SENSOR_ID_1),
CONF_DOMAIN: "3",
}
],
},
},
CONF_SENSOR: [
{
CONF_ID: Extend(TEST_SENSOR_ID_1),
CONF_UPDATE_INTERVAL: TEST_SENSOR_UPDATE_INTERVAL,
},
{CONF_ID: Extend(TEST_SENSOR_ID_2), CONF_NAME: TEST_SENSOR_NAME_1},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_2,
},
],
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
CONF_UPDATE_INTERVAL: TEST_SENSOR_UPDATE_INTERVAL,
CONF_DOMAIN: "3",
},
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_2,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
def test_package_merge_by_id_with_list() -> None:
"""
Ensures that components with matching IDs are merged correctly when their configuration contains lists.
For example, a sensor with filters defined in both a package and the top level config should be merged.
"""
config = {
CONF_PACKAGES: {
"sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
}
]
}
},
CONF_SENSOR: [
{
CONF_ID: Extend(TEST_SENSOR_ID_1),
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
}
],
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}, {CONF_OFFSET: 146.0}],
}
]
}
actual = packages_pass(config)
assert actual == expected
def test_package_merge_by_missing_id() -> None:
"""
Ensures that a validation error is thrown when trying to extend a missing ID.
"""
config = {
CONF_PACKAGES: {
"sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
},
]
}
},
CONF_SENSOR: [
{CONF_ID: TEST_SENSOR_ID_1, CONF_FILTERS: [{CONF_MULTIPLY: 10.0}]},
{
CONF_ID: Extend(TEST_SENSOR_ID_2),
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
},
],
}
error_raised = False
try:
packages_pass(config)
assert False, "Expected validation error for missing ID"
except cv.Invalid as err:
error_raised = True
assert err.path == [CONF_SENSOR, 2]
assert error_raised
def test_package_list_remove_by_id() -> None:
"""
Ensures that components with matching IDs are removed correctly.
In this test, two sensors are defined in a package, and one of them is removed at the top level.
"""
config = {
CONF_PACKAGES: {
"package_sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
},
# "package2": {
# CONF_SENSOR: [
# {
# CONF_ID: Remove(TEST_SENSOR_ID_1),
# }
# ],
# },
},
CONF_SENSOR: [
{
CONF_ID: Remove(TEST_SENSOR_ID_1),
},
],
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
def test_multiple_package_list_remove_by_id() -> None:
"""
Ensures that components with matching IDs are removed correctly.
In this test, two sensors are defined in a package, and one of them is removed in another package.
"""
config = {
CONF_PACKAGES: {
"package_sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
},
"package2": {
CONF_SENSOR: [
{
CONF_ID: Remove(TEST_SENSOR_ID_1),
}
],
},
},
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_2,
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
def test_package_dict_remove_by_id(basic_wifi, basic_esphome) -> None:
"""
Ensures that components with missing IDs are removed from dict.
Ensures that the top-level configuration takes precedence over duplicate keys defined in a package.
In this test, CONF_SSID should be overwritten by that defined in the top-level config.
"""
config = {
CONF_ESPHOME: basic_esphome,
CONF_PACKAGES: {"network": {CONF_WIFI: basic_wifi}},
CONF_WIFI: Remove(),
}
expected = {
CONF_ESPHOME: basic_esphome,
}
actual = packages_pass(config)
assert actual == expected
def test_package_remove_by_missing_id() -> None:
"""
Ensures that components with missing IDs are not merged.
"""
config = {
CONF_PACKAGES: {
"sensors": {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
},
]
}
},
"missing_key": Remove(),
CONF_SENSOR: [
{CONF_ID: TEST_SENSOR_ID_1, CONF_FILTERS: [{CONF_MULTIPLY: 10.0}]},
{
CONF_ID: Remove(TEST_SENSOR_ID_2),
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
},
],
}
expected = {
CONF_SENSOR: [
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
},
{
CONF_ID: TEST_SENSOR_ID_1,
CONF_FILTERS: [{CONF_MULTIPLY: 10.0}],
},
],
}
actual = packages_pass(config)
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(
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": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_FILES: [
{CONF_PATH: TEST_YAML_FILENAME},
"sensor2.yaml",
],
CONF_REFRESH: "1d",
}
}
}
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")
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")
def test_remote_packages_with_files_and_vars(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""
Ensures that packages are loaded as mixed list of dictionary and strings with vars
"""
# 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_DEFAULTS: {CONF_NAME: TEST_SENSOR_NAME_1},
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: "${name}",
}
],
}
),
OrderedDict(
{
CONF_DEFAULTS: {CONF_NAME: TEST_SENSOR_NAME_1},
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: "${name}",
}
],
}
),
OrderedDict(
{
CONF_DEFAULTS: {CONF_NAME: TEST_SENSOR_NAME_1},
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: "${name}",
}
],
}
),
]
# Define the input config
config = {
CONF_PACKAGES: {
"package1": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_FILES: [
{
CONF_PATH: TEST_YAML_FILENAME,
CONF_VARS: {CONF_NAME: TEST_SENSOR_NAME_2},
},
{
CONF_PATH: TEST_YAML_FILENAME,
CONF_VARS: {CONF_NAME: TEST_SENSOR_NAME_3},
},
{CONF_PATH: TEST_YAML_FILENAME},
],
CONF_REFRESH: "1d",
}
}
}
expected = {
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_3,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
]
}
actual = packages_pass(config)
assert actual == expected
def test_packages_merge_substitutions() -> None:
"""
Tests that substitutions from packages in a complex package hierarchy
are extracted and merged into the top-level config.
"""
config = {
CONF_SUBSTITUTIONS: {
"a": 1,
"b": 2,
"c": 3,
},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
CONF_SUBSTITUTIONS: {
"a": 10,
"e": 5,
},
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
CONF_SUBSTITUTIONS: {
"b": 20,
"d": 4,
},
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
CONF_SUBSTITUTIONS: {
"b": 20,
"d": 6,
},
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
expected = {
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
actual = do_packages_pass(config, command_line_substitutions={})
assert actual == expected
def test_package_merge() -> None:
"""
Tests that all packages are merged into the top-level config.
"""
config = {
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
expected = {
"sensor": [
{"platform": "template", "id": "sensor1"},
{"platform": "template", "id": "sensor2"},
{"platform": "template", "id": "sensor3"},
{"platform": "template", "id": "sensor4"},
],
"logger": {"level": "VERBOSE"},
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
}
actual = merge_packages(config)
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)
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a list, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = [package_content]
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a dict, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = {"network": package_content}
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_invalid_type_raises(
mock_resolve_include,
) -> None:
"""When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised."""
include_file = MagicMock(spec=IncludeFile)
mock_resolve_include.return_value = "not_a_dict_or_list"
config = {CONF_PACKAGES: include_file}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
) as exc_info:
do_packages_pass(config)
assert exc_info.value.path == [CONF_PACKAGES]
@pytest.mark.parametrize(
"invalid_package",
[
6,
"some string",
True,
],
)
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_named_dict_with_include_files_no_false_deprecation_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Package errors in named dicts must not trigger the deprecated fallback."""
good_include = MagicMock(spec=IncludeFile)
bad_include = MagicMock(spec=IncludeFile)
config = {
CONF_PACKAGES: {
"good_pkg": good_include,
"bad_pkg": bad_include,
},
}
call_count = 0
def failing_callback(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal call_count
call_count += 1
if call_count == 1:
# First package processes fine
return {CONF_WIFI: {CONF_SSID: "test"}}
# Second package has an error (e.g. jinja syntax error)
raise cv.Invalid("simulated jinja error in bad_pkg")
with (
caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="simulated jinja error"),
):
_walk_packages(config, failing_callback)
# Must NOT emit the deprecated single-package warning
assert "deprecated" not in caplog.text.lower()
def test_validate_deprecated_false_raises_directly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With validate_deprecated=False, errors raise directly without fallback.
This is the codepath used for remote packages where _process_remote_package
returns already-resolved dicts that is_package_definition cannot detect.
"""
config = {
CONF_PACKAGES: {
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
"pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}},
},
}
call_count = 0
def failing_callback(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal call_count
call_count += 1
if call_count == 1:
return package_config
raise cv.Invalid("nested error")
with (
caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="nested error"),
):
_walk_packages(config, failing_callback, validate_deprecated=False)
assert "deprecated" not in caplog.text.lower()
def test_error_on_first_declared_package_still_detected() -> None:
"""When the first declared package errors, it's the last processed in reverse.
All other entries are already resolved to dicts, but the failing entry
retains its original IncludeFile value since assignment was skipped.
"""
config = {
CONF_PACKAGES: {
"first_pkg": MagicMock(spec=IncludeFile),
"second_pkg": MagicMock(spec=IncludeFile),
"third_pkg": MagicMock(spec=IncludeFile),
},
}
call_count = 0
def fail_on_last(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal call_count
call_count += 1
# Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3)
if call_count < 3:
return {CONF_WIFI: {CONF_SSID: "test"}}
raise cv.Invalid("error in first_pkg")
with pytest.raises(cv.Invalid, match="error in first_pkg"):
_walk_packages(config, fail_on_last)
def test_deprecated_single_package_fallback_still_works(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated single-package form still falls back at the top level.
When a dict's values are plain config fragments (not package definitions)
and the callback fails, the deprecated fallback wraps the dict in a list
and retries with a deprecation warning.
"""
config = {
CONF_PACKAGES: {
CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"},
},
}
attempt = 0
def fail_then_succeed(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal attempt
attempt += 1
if attempt == 1:
# First attempt: treating as named dict fails
raise cv.Invalid("not a valid package")
# Second attempt: after fallback wraps as list, succeeds
return package_config
with caplog.at_level(logging.WARNING):
_walk_packages(config, fail_then_succeed)
assert "deprecated" in caplog.text.lower()
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.
This is a regression test for the bug where CORE.raw_config was set before
packages were merged, causing KeyError when components accessed
CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package.
"""
# Create a config where esphome section comes from a package
test_config = OrderedDict()
test_config[CONF_PACKAGES] = {
"base": {
CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME},
}
}
test_config["esp32"] = {"board": "esp32dev"}
# Set up CORE for the test
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config")
CORE.reset()
CORE.config_path = test_yaml
# Call validate_config - this should merge packages and set CORE.raw_config
config_module.validate_config(test_config, {})
# Verify that CORE.raw_config contains the esphome section from the package
assert CONF_ESPHOME in CORE.raw_config, (
"CORE.raw_config should contain esphome section after package merge"
)
assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME
# ---------------------------------------------------------------------------
# _substitute_package_definition
# ---------------------------------------------------------------------------
def test_substitute_package_definition_local_dict_returned_unchanged() -> None:
"""A plain local config dict is not substituted and is returned as-is."""
pkg = {CONF_WIFI: {CONF_SSID: "test"}}
result = _substitute_package_definition(pkg, ContextVars())
assert result is pkg
def test_substitute_package_definition_string_resolved_with_context() -> None:
"""A string package definition has its variables substituted."""
ctx = ContextVars({"variant": "esp32"})
result = _substitute_package_definition("device-${variant}.yaml", ctx)
assert result == "device-esp32.yaml"
def test_substitute_package_definition_undefined_in_string() -> None:
"""An undefined variable in a package URL string raises cv.Invalid."""
with pytest.raises(cv.Invalid, match="Undefined variable in package definition"):
_substitute_package_definition(
"github://org/repo/${undefined_var}/pkg.yaml", ContextVars()
)
def test_substitute_package_definition_undefined_in_remote_dict_field() -> None:
"""An undefined variable inside a remote-dict field names the offending field."""
with pytest.raises(cv.Invalid) as exc_info:
_substitute_package_definition(
{CONF_URL: "github://${typo}/repo"}, ContextVars()
)
err = str(exc_info.value)
assert "'typo' is undefined" in err
assert CONF_URL in err
def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> (
None
):
"""The field path joins correctly for non-first dict fields (e.g. ``ref``)."""
with pytest.raises(cv.Invalid) as exc_info:
_substitute_package_definition(
{
CONF_URL: "github://org/repo",
CONF_REF: "branch-${branch_typo}",
},
ContextVars(),
)
err = str(exc_info.value)
assert "'branch_typo' is undefined" in err
assert CONF_REF in err
def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None:
"""A package loaded from YAML surfaces file/line/col in the cv.Invalid message.
Line/column are rendered 1-based (matching config.line_info() and editor
line numbering) and point at the offending scalar, not the enclosing dict.
"""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n"
)
config = load_yaml(yaml_file)
package_config = config[CONF_PACKAGES]["broken"]
with pytest.raises(cv.Invalid) as exc_info:
_substitute_package_definition(package_config, ContextVars())
err = str(exc_info.value)
assert "main.yaml" in err
# The offending value lives on line 2 (1-based). Column depends on the YAML
# loader, so we only pin line and check that a 1-based column is present.
match = re.search(r"main\.yaml (\d+):(\d+)", err)
assert match, err
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 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"
# ---------------------------------------------------------------------------
# resolve_packages — single-call wrapper around do_packages_pass + merge_packages
# ---------------------------------------------------------------------------
def test_resolve_packages_returns_config_unchanged_without_packages() -> None:
"""No ``packages:`` key → no-op, same dict back."""
config = {CONF_ESPHOME: {CONF_NAME: "test"}, CONF_WIFI: {CONF_SSID: "x"}}
result = resolve_packages(config)
assert result is config
assert CONF_PACKAGES not in result
def test_resolve_packages_loads_and_merges_in_one_call() -> None:
"""End-to-end: a config with one local-dict package gets its blocks flattened."""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_PACKAGES: {
"shared": {
CONF_WIFI: {CONF_SSID: "from_package"},
CONF_SENSOR: [
{CONF_PLATFORM: "template", CONF_NAME: "from_package_sensor"},
],
}
},
}
result = resolve_packages(config)
# ``packages:`` is gone — it was consumed by the merge.
assert CONF_PACKAGES not in result
# Blocks contributed by the package are now top-level.
assert result[CONF_WIFI][CONF_SSID] == "from_package"
assert result[CONF_SENSOR][0][CONF_NAME] == "from_package_sensor"
# The main config's own keys survive untouched.
assert result[CONF_ESPHOME][CONF_NAME] == "main"
def test_resolve_packages_preserves_main_config_overrides() -> None:
"""Main-config values win over package values for the same key.
Pinning the precedence ESPHome's compiler uses so any future
refactor of the wrapper doesn't accidentally flip the order.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_WIFI: {CONF_SSID: "main_wins"},
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "package_loses"}},
},
}
result = resolve_packages(config)
assert result[CONF_WIFI][CONF_SSID] == "main_wins"
def test_resolve_packages_forwards_command_line_substitutions() -> None:
"""``command_line_substitutions`` reaches the underlying ``do_packages_pass``.
The wrapper exists so external tools have one stable seam; if
that seam silently dropped a kwarg the underlying call accepts,
callers would see surprising behaviour. This pins the
pass-through.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_PACKAGES: {"shared": {CONF_WIFI: {CONF_SSID: "from_package"}}},
}
with patch(
"esphome.components.packages.do_packages_pass",
wraps=do_packages_pass,
) as spy:
resolve_packages(config, command_line_substitutions={"foo": "bar"})
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("command_line_substitutions") == {"foo": "bar"}
def test_resolve_packages_does_not_run_substitutions() -> None:
"""``${var}`` placeholders inside package content stay literal.
The full ``validate_config`` pipeline runs ``do_substitution_pass``
BETWEEN ``do_packages_pass`` and ``merge_packages``; this wrapper
skips it on purpose. Pin that contract so a future refactor can't
silently start resolving substitutions and break callers that
deliberately compose the passes themselves.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_SUBSTITUTIONS: {"ssid_value": "resolved_ssid"},
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "${ssid_value}"}},
},
}
result = resolve_packages(config)
# Without ``do_substitution_pass`` the placeholder is preserved.
assert result[CONF_WIFI][CONF_SSID] == "${ssid_value}"
def test_resolve_packages_does_not_apply_extend_remove() -> None:
"""Top-level ``!remove`` / ``!extend`` markers stay in the merged dict.
The full ``validate_config`` pipeline runs ``resolve_extend_remove``
AFTER ``merge_packages``; this wrapper skips it on purpose. Pin
that contract: a package-contributed block paired with a top-level
``!remove`` is left as-is for callers to handle (or for them to
call ``resolve_extend_remove`` themselves).
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_WIFI: Remove(),
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "from_package"}},
},
}
result = resolve_packages(config)
# ``merge_packages`` keeps the top-level ``!remove`` (it wins
# over the package value during merge), and the marker is not
# resolved by this wrapper.
assert isinstance(result[CONF_WIFI], Remove)