[packages] Remove deprecated single-package include syntax (#17119)

This commit is contained in:
J. Nick Koston
2026-06-21 15:00:46 -05:00
committed by GitHub
parent 78c6131bbf
commit c6ead57a9e
2 changed files with 18 additions and 183 deletions

View File

@@ -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]

View File

@@ -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 = {