From c6ead57a9ef7a74556da54da6e2b0ebd93fac729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:46 -0500 Subject: [PATCH] [packages] Remove deprecated single-package include syntax (#17119) --- esphome/components/packages/__init__.py | 61 +------- .../component_tests/packages/test_packages.py | 140 ++---------------- 2 files changed, 18 insertions(+), 183 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index c1c5bd2ae3..44a1ebf36e 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,6 @@ from collections import UserDict from collections.abc import Callable from functools import reduce -import logging from pathlib import Path from typing import Any @@ -36,8 +35,6 @@ from esphome.const import ( ) from esphome.core import EsphomeError -_LOGGER = logging.getLogger(__name__) - DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool: 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: """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) -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( 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 } ), - [PACKAGE_SCHEMA], # a list of package definitions, or - cv.All( # a single package definition (deprecated) - cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package - ), + [PACKAGE_SCHEMA], # a list of package definitions ) @@ -348,7 +314,6 @@ def _walk_packages( config: dict, callback: PackageCallback, context: ContextVars | None = None, - validate_deprecated: bool = True, path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -378,17 +343,7 @@ def _walk_packages( elif ( result := _walk_package_dict(packages, callback, context, packages_path) ) is not None: - if not validate_deprecated or any( - 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 - ) + raise result config[CONF_PACKAGES] = packages return config @@ -588,9 +543,6 @@ class _PackageProcessor: path: yaml_util.DocumentPath, ) -> dict: """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) 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 # was already pushed in collect_substitutions above). 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->). return _walk_packages( package_config, self.process_package, context_vars, - validate_deprecated=not from_remote, path=path, ) @@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict: merge_list.append(package_config) 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: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 66f946a5bd..6990c1c051 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -12,7 +12,6 @@ from esphome.components.packages import ( _substitute_package_definition, _walk_packages, do_packages_pass, - is_package_definition, merge_packages, resolve_packages, ) @@ -89,44 +88,6 @@ def packages_pass(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. @@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None: 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. @@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises( 6, "some string", True, + None, + ["some string"], + {"some_component": 8}, + {3: 2}, ], ) 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) -@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.""" +def test_single_package_fragment_form_rejected() -> None: + """The deprecated single-package form is removed and now raises. + + Previously ``packages: !include some_package.yaml`` resolving to a bare config + fragment dict was silently wrapped and merged via the single-package fallback. + That form must now raise instead of being accepted. + """ config = { - CONF_PACKAGES: { - "some_package": invalid_package, - }, + CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}}, } with pytest.raises(cv.Invalid): 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() -def test_validate_deprecated_false_raises_directly( +def test_named_package_errors_raise_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. - """ + """Errors processing a named-dict package raise directly, with no deprecation warning.""" config = { CONF_PACKAGES: { "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, @@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly( caplog.at_level(logging.WARNING), 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() @@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None: _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 = {