mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 13:27:14 +00:00
[packages] Remove deprecated single-package include syntax (#17119)
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
from collections import UserDict
|
from collections import UserDict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -36,8 +35,6 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import EsphomeError
|
from esphome.core import EsphomeError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DOMAIN = CONF_PACKAGES
|
DOMAIN = CONF_PACKAGES
|
||||||
# Guard against infinite include chains (e.g. A includes B includes A).
|
# Guard against infinite include chains (e.g. A includes B includes A).
|
||||||
MAX_INCLUDE_DEPTH = 20
|
MAX_INCLUDE_DEPTH = 20
|
||||||
@@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool:
|
|||||||
return CONF_URL in package_config
|
return CONF_URL in package_config
|
||||||
|
|
||||||
|
|
||||||
def is_package_definition(value: object) -> bool:
|
|
||||||
"""Returns True if the value looks like a package definition rather than a config fragment.
|
|
||||||
|
|
||||||
Package definitions are IncludeFile objects, git URL shorthand strings, or
|
|
||||||
remote package dicts (containing a ``url:`` key). Config fragments are
|
|
||||||
plain dicts that represent component configuration.
|
|
||||||
"""
|
|
||||||
return isinstance(value, (yaml_util.IncludeFile, str)) or (
|
|
||||||
isinstance(value, dict) and is_remote_package(value)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def valid_package_contents(package_config: dict) -> dict:
|
def valid_package_contents(package_config: dict) -> dict:
|
||||||
"""Validate that a package looks like a plausible ESPHome config fragment.
|
"""Validate that a package looks like a plausible ESPHome config fragment.
|
||||||
|
|
||||||
@@ -134,22 +119,6 @@ def validate_source_shorthand(value):
|
|||||||
return REMOTE_PACKAGE_SCHEMA(conf)
|
return REMOTE_PACKAGE_SCHEMA(conf)
|
||||||
|
|
||||||
|
|
||||||
def deprecate_single_package(config: dict) -> dict:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"""
|
|
||||||
Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated.
|
|
||||||
This method for including packages will go away in 2026.7.0
|
|
||||||
Please use a list instead:
|
|
||||||
|
|
||||||
packages:
|
|
||||||
- !include mypackage.yaml
|
|
||||||
|
|
||||||
See https://github.com/esphome/esphome/pull/12116
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
REMOTE_PACKAGE_SCHEMA = cv.All(
|
REMOTE_PACKAGE_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
|
|||||||
str: PACKAGE_SCHEMA, # a named dict of package definitions, or
|
str: PACKAGE_SCHEMA, # a named dict of package definitions, or
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
[PACKAGE_SCHEMA], # a list of package definitions, or
|
[PACKAGE_SCHEMA], # a list of package definitions
|
||||||
cv.All( # a single package definition (deprecated)
|
|
||||||
cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -348,7 +314,6 @@ def _walk_packages(
|
|||||||
config: dict,
|
config: dict,
|
||||||
callback: PackageCallback,
|
callback: PackageCallback,
|
||||||
context: ContextVars | None = None,
|
context: ContextVars | None = None,
|
||||||
validate_deprecated: bool = True,
|
|
||||||
path: yaml_util.DocumentPath | None = None,
|
path: yaml_util.DocumentPath | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Walks the packages structure in priority order, invoking ``callback`` on each package definition found.
|
"""Walks the packages structure in priority order, invoking ``callback`` on each package definition found.
|
||||||
@@ -378,17 +343,7 @@ def _walk_packages(
|
|||||||
elif (
|
elif (
|
||||||
result := _walk_package_dict(packages, callback, context, packages_path)
|
result := _walk_package_dict(packages, callback, context, packages_path)
|
||||||
) is not None:
|
) is not None:
|
||||||
if not validate_deprecated or any(
|
raise result
|
||||||
is_package_definition(v) for v in packages.values()
|
|
||||||
):
|
|
||||||
raise result
|
|
||||||
# Fallback: treat the dict as a single deprecated package.
|
|
||||||
# This block can be removed once the single-package
|
|
||||||
# deprecation period (2026.7.0) is over.
|
|
||||||
config[CONF_PACKAGES] = [packages]
|
|
||||||
return _walk_packages(
|
|
||||||
deprecate_single_package(config), callback, context, path=path
|
|
||||||
)
|
|
||||||
|
|
||||||
config[CONF_PACKAGES] = packages
|
config[CONF_PACKAGES] = packages
|
||||||
return config
|
return config
|
||||||
@@ -588,9 +543,6 @@ class _PackageProcessor:
|
|||||||
path: yaml_util.DocumentPath,
|
path: yaml_util.DocumentPath,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Resolve a single package and recurse into any nested packages."""
|
"""Resolve a single package and recurse into any nested packages."""
|
||||||
from_remote = isinstance(package_config, dict) and is_remote_package(
|
|
||||||
package_config
|
|
||||||
)
|
|
||||||
package_config = self.resolve_package(package_config, context_vars, path)
|
package_config = self.resolve_package(package_config, context_vars, path)
|
||||||
context_vars = self.collect_substitutions(package_config, context_vars)
|
context_vars = self.collect_substitutions(package_config, context_vars)
|
||||||
|
|
||||||
@@ -600,17 +552,10 @@ class _PackageProcessor:
|
|||||||
# Push context from !include vars on the packages key (the package root
|
# Push context from !include vars on the packages key (the package root
|
||||||
# was already pushed in collect_substitutions above).
|
# was already pushed in collect_substitutions above).
|
||||||
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
|
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
|
||||||
# Disable the deprecated single-package fallback for remote
|
|
||||||
# packages. _process_remote_package returns dicts with
|
|
||||||
# already-resolved values that is_package_definition cannot
|
|
||||||
# distinguish from config fragments, so the fallback would
|
|
||||||
# always fire and mask real errors with wrong paths
|
|
||||||
# (packages->0 instead of packages-><name>).
|
|
||||||
return _walk_packages(
|
return _walk_packages(
|
||||||
package_config,
|
package_config,
|
||||||
self.process_package,
|
self.process_package,
|
||||||
context_vars,
|
context_vars,
|
||||||
validate_deprecated=not from_remote,
|
|
||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict:
|
|||||||
merge_list.append(package_config)
|
merge_list.append(package_config)
|
||||||
return _walk_packages(package_config, process_package_callback, path=path)
|
return _walk_packages(package_config, process_package_callback, path=path)
|
||||||
|
|
||||||
_walk_packages(config, process_package_callback, validate_deprecated=False)
|
_walk_packages(config, process_package_callback)
|
||||||
# Merge all packages into the main config:
|
# Merge all packages into the main config:
|
||||||
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
|
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
|
||||||
del config[CONF_PACKAGES]
|
del config[CONF_PACKAGES]
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from esphome.components.packages import (
|
|||||||
_substitute_package_definition,
|
_substitute_package_definition,
|
||||||
_walk_packages,
|
_walk_packages,
|
||||||
do_packages_pass,
|
do_packages_pass,
|
||||||
is_package_definition,
|
|
||||||
merge_packages,
|
merge_packages,
|
||||||
resolve_packages,
|
resolve_packages,
|
||||||
)
|
)
|
||||||
@@ -89,44 +88,6 @@ def packages_pass(config):
|
|||||||
return 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:
|
def test_package_unused(basic_esphome, basic_wifi) -> None:
|
||||||
"""
|
"""
|
||||||
Ensures do_package_pass does not change a config if packages aren't used.
|
Ensures do_package_pass does not change a config if packages aren't used.
|
||||||
@@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None:
|
|||||||
assert actual == expected
|
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:
|
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.
|
Tests the case where a key is present in both a package and top-level config.
|
||||||
@@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises(
|
|||||||
6,
|
6,
|
||||||
"some string",
|
"some string",
|
||||||
True,
|
True,
|
||||||
|
None,
|
||||||
|
["some string"],
|
||||||
|
{"some_component": 8},
|
||||||
|
{3: 2},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_invalid_package_contents_rejected(invalid_package: object) -> None:
|
def test_invalid_package_contents_rejected(invalid_package: object) -> None:
|
||||||
@@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None:
|
|||||||
do_packages_pass(config)
|
do_packages_pass(config)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(
|
def test_single_package_fragment_form_rejected() -> None:
|
||||||
reason="Deprecated single-package fallback swallows these errors. "
|
"""The deprecated single-package form is removed and now raises.
|
||||||
"Remove xfail when single-package deprecation is removed (2026.7.0).",
|
|
||||||
strict=True,
|
Previously ``packages: !include some_package.yaml`` resolving to a bare config
|
||||||
)
|
fragment dict was silently wrapped and merged via the single-package fallback.
|
||||||
@pytest.mark.parametrize(
|
That form must now raise instead of being accepted.
|
||||||
"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 = {
|
config = {
|
||||||
CONF_PACKAGES: {
|
CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}},
|
||||||
"some_package": invalid_package,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
with pytest.raises(cv.Invalid):
|
with pytest.raises(cv.Invalid):
|
||||||
do_packages_pass(config)
|
do_packages_pass(config)
|
||||||
@@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning(
|
|||||||
assert "deprecated" not in caplog.text.lower()
|
assert "deprecated" not in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_validate_deprecated_false_raises_directly(
|
def test_named_package_errors_raise_directly(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""With validate_deprecated=False, errors raise directly without fallback.
|
"""Errors processing a named-dict package raise directly, with no deprecation warning."""
|
||||||
|
|
||||||
This is the codepath used for remote packages where _process_remote_package
|
|
||||||
returns already-resolved dicts that is_package_definition cannot detect.
|
|
||||||
"""
|
|
||||||
config = {
|
config = {
|
||||||
CONF_PACKAGES: {
|
CONF_PACKAGES: {
|
||||||
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
|
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
|
||||||
@@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly(
|
|||||||
caplog.at_level(logging.WARNING),
|
caplog.at_level(logging.WARNING),
|
||||||
pytest.raises(cv.Invalid, match="nested error"),
|
pytest.raises(cv.Invalid, match="nested error"),
|
||||||
):
|
):
|
||||||
_walk_packages(config, failing_callback, validate_deprecated=False)
|
_walk_packages(config, failing_callback)
|
||||||
|
|
||||||
assert "deprecated" not in caplog.text.lower()
|
assert "deprecated" not in caplog.text.lower()
|
||||||
|
|
||||||
@@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None:
|
|||||||
_walk_packages(config, fail_on_last)
|
_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:
|
def test_merge_packages_invalid_nested_type_raises() -> None:
|
||||||
"""Invalid nested packages type during merge raises cv.Invalid."""
|
"""Invalid nested packages type during merge raises cv.Invalid."""
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user