mirror of
https://github.com/esphome/esphome.git
synced 2025-10-07 20:33:47 +01:00
[core] Allow AUTO_LOAD
to receive the component config to determine if it should load other components (#10961)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
@@ -67,6 +67,31 @@ ConfigPath = list[str | int]
|
|||||||
path_context = contextvars.ContextVar("Config path")
|
path_context = contextvars.ContextVar("Config path")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_auto_load_steps(result: Config, loads: list[str]) -> None:
|
||||||
|
"""Add AutoLoadValidationStep for each component in loads that isn't already loaded."""
|
||||||
|
for load in loads:
|
||||||
|
if load not in result:
|
||||||
|
result.add_validation_step(AutoLoadValidationStep(load))
|
||||||
|
|
||||||
|
|
||||||
|
def _process_auto_load(
|
||||||
|
result: Config, platform: ComponentManifest, path: ConfigPath
|
||||||
|
) -> None:
|
||||||
|
# Process platform's AUTO_LOAD
|
||||||
|
auto_load = platform.auto_load
|
||||||
|
if isinstance(auto_load, list):
|
||||||
|
_add_auto_load_steps(result, auto_load)
|
||||||
|
elif callable(auto_load):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
if inspect.signature(auto_load).parameters:
|
||||||
|
result.add_validation_step(
|
||||||
|
AddDynamicAutoLoadsValidationStep(path, platform)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add_auto_load_steps(result, auto_load())
|
||||||
|
|
||||||
|
|
||||||
def _process_platform_config(
|
def _process_platform_config(
|
||||||
result: Config,
|
result: Config,
|
||||||
component_name: str,
|
component_name: str,
|
||||||
@@ -91,9 +116,7 @@ def _process_platform_config(
|
|||||||
CORE.loaded_platforms.add(f"{component_name}/{platform_name}")
|
CORE.loaded_platforms.add(f"{component_name}/{platform_name}")
|
||||||
|
|
||||||
# Process platform's AUTO_LOAD
|
# Process platform's AUTO_LOAD
|
||||||
for load in platform.auto_load:
|
_process_auto_load(result, platform, path)
|
||||||
if load not in result:
|
|
||||||
result.add_validation_step(AutoLoadValidationStep(load))
|
|
||||||
|
|
||||||
# Add validation steps for the platform
|
# Add validation steps for the platform
|
||||||
p_domain = f"{component_name}.{platform_name}"
|
p_domain = f"{component_name}.{platform_name}"
|
||||||
@@ -390,9 +413,7 @@ class LoadValidationStep(ConfigValidationStep):
|
|||||||
result[self.domain] = self.conf = [self.conf]
|
result[self.domain] = self.conf = [self.conf]
|
||||||
|
|
||||||
# Process AUTO_LOAD
|
# Process AUTO_LOAD
|
||||||
for load in component.auto_load:
|
_process_auto_load(result, component, path)
|
||||||
if load not in result:
|
|
||||||
result.add_validation_step(AutoLoadValidationStep(load))
|
|
||||||
|
|
||||||
result.add_validation_step(
|
result.add_validation_step(
|
||||||
MetadataValidationStep([self.domain], self.domain, self.conf, component)
|
MetadataValidationStep([self.domain], self.domain, self.conf, component)
|
||||||
@@ -618,6 +639,34 @@ class MetadataValidationStep(ConfigValidationStep):
|
|||||||
result.add_validation_step(FinalValidateValidationStep(self.path, self.comp))
|
result.add_validation_step(FinalValidateValidationStep(self.path, self.comp))
|
||||||
|
|
||||||
|
|
||||||
|
class AddDynamicAutoLoadsValidationStep(ConfigValidationStep):
|
||||||
|
"""Add dynamic auto loads step.
|
||||||
|
|
||||||
|
This step is used to auto-load components where one component can alter its
|
||||||
|
AUTO_LOAD based on its configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Has to happen after normal schema is validated and before final schema validation
|
||||||
|
priority = -10.0
|
||||||
|
|
||||||
|
def __init__(self, path: ConfigPath, comp: ComponentManifest) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.comp = comp
|
||||||
|
|
||||||
|
def run(self, result: Config) -> None:
|
||||||
|
if result.errors:
|
||||||
|
# If result already has errors, skip this step
|
||||||
|
return
|
||||||
|
|
||||||
|
conf = result.get_nested_item(self.path)
|
||||||
|
with result.catch_error(self.path):
|
||||||
|
auto_load = self.comp.auto_load
|
||||||
|
if not callable(auto_load):
|
||||||
|
return
|
||||||
|
loads = auto_load(conf)
|
||||||
|
_add_auto_load_steps(result, loads)
|
||||||
|
|
||||||
|
|
||||||
class SchemaValidationStep(ConfigValidationStep):
|
class SchemaValidationStep(ConfigValidationStep):
|
||||||
"""Schema validation step.
|
"""Schema validation step.
|
||||||
|
|
||||||
|
@@ -82,11 +82,10 @@ class ComponentManifest:
|
|||||||
return getattr(self.module, "CONFLICTS_WITH", [])
|
return getattr(self.module, "CONFLICTS_WITH", [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auto_load(self) -> list[str]:
|
def auto_load(
|
||||||
al = getattr(self.module, "AUTO_LOAD", [])
|
self,
|
||||||
if callable(al):
|
) -> list[str] | Callable[[], list[str]] | Callable[[ConfigType], list[str]]:
|
||||||
return al()
|
return getattr(self.module, "AUTO_LOAD", [])
|
||||||
return al
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def codeowners(self) -> list[str]:
|
def codeowners(self) -> list[str]:
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ from esphome.const import (
|
|||||||
PLATFORM_ESP8266,
|
PLATFORM_ESP8266,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
from esphome.loader import get_component, get_platform
|
from esphome.loader import ComponentManifest, get_component, get_platform
|
||||||
|
|
||||||
|
|
||||||
def filter_component_files(str):
|
def filter_component_files(str):
|
||||||
@@ -45,6 +46,29 @@ def add_item_to_components_graph(components_graph, parent, child):
|
|||||||
components_graph[parent].append(child)
|
components_graph[parent].append(child)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_auto_load(
|
||||||
|
auto_load: list[str] | Callable[[], list[str]] | Callable[[dict | None], list[str]],
|
||||||
|
config: dict | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Resolve AUTO_LOAD to a list, handling callables with or without config parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auto_load: The AUTO_LOAD value (list or callable)
|
||||||
|
config: Optional config to pass to callable AUTO_LOAD functions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of component names to auto-load
|
||||||
|
"""
|
||||||
|
if not callable(auto_load):
|
||||||
|
return auto_load
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
if inspect.signature(auto_load).parameters:
|
||||||
|
return auto_load(config)
|
||||||
|
return auto_load()
|
||||||
|
|
||||||
|
|
||||||
def create_components_graph():
|
def create_components_graph():
|
||||||
# The root directory of the repo
|
# The root directory of the repo
|
||||||
root = Path(__file__).parent.parent
|
root = Path(__file__).parent.parent
|
||||||
@@ -63,7 +87,7 @@ def create_components_graph():
|
|||||||
|
|
||||||
components_graph = {}
|
components_graph = {}
|
||||||
platforms = []
|
platforms = []
|
||||||
components = []
|
components: list[tuple[ComponentManifest, str, Path]] = []
|
||||||
|
|
||||||
for path in components_dir.iterdir():
|
for path in components_dir.iterdir():
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
@@ -92,8 +116,8 @@ def create_components_graph():
|
|||||||
|
|
||||||
for target_config in TARGET_CONFIGURATIONS:
|
for target_config in TARGET_CONFIGURATIONS:
|
||||||
CORE.data[KEY_CORE] = target_config
|
CORE.data[KEY_CORE] = target_config
|
||||||
for auto_load in comp.auto_load:
|
for item in resolve_auto_load(comp.auto_load, config=None):
|
||||||
add_item_to_components_graph(components_graph, auto_load, name)
|
add_item_to_components_graph(components_graph, item, name)
|
||||||
# restore config
|
# restore config
|
||||||
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
||||||
|
|
||||||
@@ -114,8 +138,8 @@ def create_components_graph():
|
|||||||
|
|
||||||
for target_config in TARGET_CONFIGURATIONS:
|
for target_config in TARGET_CONFIGURATIONS:
|
||||||
CORE.data[KEY_CORE] = target_config
|
CORE.data[KEY_CORE] = target_config
|
||||||
for auto_load in platform.auto_load:
|
for item in resolve_auto_load(platform.auto_load, config={}):
|
||||||
add_item_to_components_graph(components_graph, auto_load, name)
|
add_item_to_components_graph(components_graph, item, name)
|
||||||
# restore config
|
# restore config
|
||||||
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
||||||
|
|
||||||
|
@@ -101,3 +101,10 @@ def mock_get_idedata() -> Generator[Mock, None, None]:
|
|||||||
"""Mock get_idedata for platformio_api."""
|
"""Mock get_idedata for platformio_api."""
|
||||||
with patch("esphome.platformio_api.get_idedata") as mock:
|
with patch("esphome.platformio_api.get_idedata") as mock:
|
||||||
yield mock
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_component() -> Generator[Mock, None, None]:
|
||||||
|
"""Mock get_component for config module."""
|
||||||
|
with patch("esphome.config.get_component") as mock:
|
||||||
|
yield mock
|
||||||
|
10
tests/unit_tests/fixtures/auto_load_dynamic.yaml
Normal file
10
tests/unit_tests/fixtures/auto_load_dynamic.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
esphome:
|
||||||
|
name: test-device
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
|
||||||
|
# Test component with dynamic AUTO_LOAD
|
||||||
|
test_component:
|
||||||
|
enable_logger: true
|
||||||
|
enable_api: false
|
8
tests/unit_tests/fixtures/auto_load_static.yaml
Normal file
8
tests/unit_tests/fixtures/auto_load_static.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
esphome:
|
||||||
|
name: test-device
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
|
||||||
|
# Test component with static AUTO_LOAD
|
||||||
|
test_component:
|
131
tests/unit_tests/test_config_auto_load.py
Normal file
131
tests/unit_tests/test_config_auto_load.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Tests for AUTO_LOAD functionality including dynamic AUTO_LOAD."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome import config, config_validation as cv, yaml_util
|
||||||
|
from esphome.core import CORE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixtures_dir() -> Path:
|
||||||
|
"""Get the fixtures directory."""
|
||||||
|
return Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_component() -> Mock:
|
||||||
|
"""Create a default mock component for unmocked components."""
|
||||||
|
return Mock(
|
||||||
|
auto_load=[],
|
||||||
|
is_platform_component=False,
|
||||||
|
is_platform=False,
|
||||||
|
multi_conf=False,
|
||||||
|
multi_conf_no_default=False,
|
||||||
|
dependencies=[],
|
||||||
|
conflicts_with=[],
|
||||||
|
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def static_auto_load_component() -> Mock:
|
||||||
|
"""Create a mock component with static AUTO_LOAD."""
|
||||||
|
return Mock(
|
||||||
|
auto_load=["logger"],
|
||||||
|
is_platform_component=False,
|
||||||
|
is_platform=False,
|
||||||
|
multi_conf=False,
|
||||||
|
multi_conf_no_default=False,
|
||||||
|
dependencies=[],
|
||||||
|
conflicts_with=[],
|
||||||
|
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_auto_load_adds_components(
|
||||||
|
mock_get_component: Mock,
|
||||||
|
fixtures_dir: Path,
|
||||||
|
static_auto_load_component: Mock,
|
||||||
|
default_component: Mock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that static AUTO_LOAD triggers loading of specified components."""
|
||||||
|
CORE.config_path = fixtures_dir / "auto_load_static.yaml"
|
||||||
|
|
||||||
|
config_file = fixtures_dir / "auto_load_static.yaml"
|
||||||
|
raw_config = yaml_util.load_yaml(config_file)
|
||||||
|
|
||||||
|
component_mocks = {"test_component": static_auto_load_component}
|
||||||
|
mock_get_component.side_effect = lambda name: component_mocks.get(
|
||||||
|
name, default_component
|
||||||
|
)
|
||||||
|
|
||||||
|
result = config.validate_config(raw_config, {})
|
||||||
|
|
||||||
|
# Check for validation errors
|
||||||
|
assert not result.errors, f"Validation errors: {result.errors}"
|
||||||
|
|
||||||
|
# Logger should have been auto-loaded by test_component
|
||||||
|
assert "logger" in result
|
||||||
|
assert "test_component" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_auto_load_with_config_param(
|
||||||
|
mock_get_component: Mock,
|
||||||
|
fixtures_dir: Path,
|
||||||
|
default_component: Mock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that dynamic AUTO_LOAD evaluates based on configuration."""
|
||||||
|
CORE.config_path = fixtures_dir / "auto_load_dynamic.yaml"
|
||||||
|
|
||||||
|
config_file = fixtures_dir / "auto_load_dynamic.yaml"
|
||||||
|
raw_config = yaml_util.load_yaml(config_file)
|
||||||
|
|
||||||
|
# Track if auto_load was called with config
|
||||||
|
auto_load_calls = []
|
||||||
|
|
||||||
|
def dynamic_auto_load(conf: dict[str, Any]) -> list[str]:
|
||||||
|
"""Dynamically load components based on config."""
|
||||||
|
auto_load_calls.append(conf)
|
||||||
|
component_map = {
|
||||||
|
"enable_logger": "logger",
|
||||||
|
"enable_api": "api",
|
||||||
|
}
|
||||||
|
return [comp for key, comp in component_map.items() if conf.get(key)]
|
||||||
|
|
||||||
|
dynamic_component = Mock(
|
||||||
|
auto_load=dynamic_auto_load,
|
||||||
|
is_platform_component=False,
|
||||||
|
is_platform=False,
|
||||||
|
multi_conf=False,
|
||||||
|
multi_conf_no_default=False,
|
||||||
|
dependencies=[],
|
||||||
|
conflicts_with=[],
|
||||||
|
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
|
||||||
|
)
|
||||||
|
|
||||||
|
component_mocks = {"test_component": dynamic_component}
|
||||||
|
mock_get_component.side_effect = lambda name: component_mocks.get(
|
||||||
|
name, default_component
|
||||||
|
)
|
||||||
|
|
||||||
|
result = config.validate_config(raw_config, {})
|
||||||
|
|
||||||
|
# Check for validation errors
|
||||||
|
assert not result.errors, f"Validation errors: {result.errors}"
|
||||||
|
|
||||||
|
# Verify auto_load was called with the validated config
|
||||||
|
assert len(auto_load_calls) == 1, "auto_load should be called exactly once"
|
||||||
|
assert auto_load_calls[0].get("enable_logger") is True
|
||||||
|
assert auto_load_calls[0].get("enable_api") is False
|
||||||
|
|
||||||
|
# Only logger should be auto-loaded (enable_logger=true in YAML)
|
||||||
|
assert "logger" in result, (
|
||||||
|
f"Logger not found in result. Result keys: {list(result.keys())}"
|
||||||
|
)
|
||||||
|
# API should NOT be auto-loaded (enable_api=false in YAML)
|
||||||
|
assert "api" not in result
|
||||||
|
assert "test_component" in result
|
@@ -10,13 +10,6 @@ from esphome import config, yaml_util
|
|||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_get_component() -> Generator[Mock, None, None]:
|
|
||||||
"""Fixture for mocking get_component."""
|
|
||||||
with patch("esphome.config.get_component") as mock_get_component:
|
|
||||||
yield mock_get_component
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_get_platform() -> Generator[Mock, None, None]:
|
def mock_get_platform() -> Generator[Mock, None, None]:
|
||||||
"""Fixture for mocking get_platform."""
|
"""Fixture for mocking get_platform."""
|
||||||
|
Reference in New Issue
Block a user