[build] Skip target-platform deps when populating host unit-test config (#17039)

This commit is contained in:
Jonathan Swoboda
2026-06-18 15:38:57 -04:00
committed by GitHub
parent a39505f5ef
commit 1a553018bf
3 changed files with 93 additions and 6 deletions

View File

@@ -70,12 +70,15 @@ def populate_dependency_config(
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends * ``domain.platform`` form (e.g. ``sensor.gpio``) appends
``{platform: <name>}`` to ``config[domain]``, creating the list if needed. ``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
* Bare components are looked up via ``get_component_fn``. Platform * Bare components are looked up via ``get_component_fn``. Target-platform
components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are components (``is_target_platform``, e.g. ``esp32``) are skipped entirely:
initialised as ``[]`` so the sibling ``domain.platform`` branch can a host build targets ``host``, so a foreign target platform's sources are
``append`` into them. Everything else is populated by running the guarded out and its schema must not run here (it would mutate global CORE
component's schema with ``{}`` so defaults exist; if the schema requires state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``)
explicit input, an empty ``{}`` is used as a fallback. and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling
``domain.platform`` branch can ``append`` into them. Everything else is
populated by running the component's schema with ``{}`` so defaults exist;
if the schema requires explicit input, an empty ``{}`` is used as a fallback.
Platform components must always be a list here even when no Platform components must always be a list here even when no
``domain.platform`` entry follows, because the ``domain.platform`` branch ``domain.platform`` entry follows, because the ``domain.platform`` branch
@@ -96,6 +99,12 @@ def populate_dependency_config(
component = get_component_fn(component_name) component = get_component_fn(component_name)
if component is None: if component is None:
continue continue
# Skip target platforms (e.g. esp32): a host build targets `host`, so a
# foreign target's sources are guarded out, and running its schema with
# {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF),
# crashing the host compile. See #17035.
if component.is_target_platform:
continue
if component.multi_conf or component.is_platform_component: if component.multi_conf or component.is_platform_component:
config.setdefault(component_name, []) config.setdefault(component_name, [])
elif component_name not in config: elif component_name not in config:

View File

@@ -0,0 +1,76 @@
"""Unit tests for script/build_helpers.py."""
from pathlib import Path
import sys
import pytest
# Add the script directory to the path so we can import build_helpers.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
import build_helpers # noqa: E402
from esphome.core import CORE # noqa: E402
class _FakeComponent:
def __init__(self, config_schema, *, is_target_platform=False):
self.multi_conf = False
self.is_platform_component = False
self.is_target_platform = is_target_platform
self.config_schema = config_schema
@pytest.fixture(autouse=True)
def _restore_core_toolchain():
"""Keep CORE.toolchain changes from leaking between tests."""
saved = CORE.toolchain
try:
yield
finally:
CORE.toolchain = saved
def test_populate_dependency_config_skips_target_platforms() -> None:
"""Target-platform deps must be skipped, not config-populated, in a host build.
Regression test for #17035: esp32 (a target platform) appears only as a
transitive dependency of a host C++ unit test. Running its schema with {}
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
which crashed the host compile with KeyError('esp32'). The fix skips
target-platform components entirely so their schema never runs.
"""
CORE.toolchain = None # the state a host build starts from
schema_calls = []
def leaky_schema(value):
# If this ever runs for a target platform, the bug is back.
schema_calls.append(value)
CORE.toolchain = "esp-idf-leak"
raise ValueError("no board or variant")
config: dict = {}
build_helpers.populate_dependency_config(
config,
["esp32"],
get_component_fn=lambda name: _FakeComponent(
leaky_schema, is_target_platform=True
),
register_platform_fn=lambda domain: None,
)
assert "esp32" not in config # skipped: no synthesized entry
assert schema_calls == [] # schema never run
assert CORE.toolchain is None # no global side effect leaked
def test_populate_dependency_config_populates_defaults() -> None:
"""A non-target-platform dep still has its schema defaults harvested."""
config: dict = {}
build_helpers.populate_dependency_config(
config,
["ok"],
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
register_platform_fn=lambda domain: None,
)
assert config["ok"] == {"default": 1}

View File

@@ -266,11 +266,13 @@ def _make_component_stub(
*, *,
multi_conf: bool = False, multi_conf: bool = False,
is_platform_component: bool = False, is_platform_component: bool = False,
is_target_platform: bool = False,
config_schema=None, config_schema=None,
) -> MagicMock: ) -> MagicMock:
stub = MagicMock() stub = MagicMock()
stub.multi_conf = multi_conf stub.multi_conf = multi_conf
stub.is_platform_component = is_platform_component stub.is_platform_component = is_platform_component
stub.is_target_platform = is_target_platform
stub.config_schema = config_schema stub.config_schema = config_schema
return stub return stub