mirror of
https://github.com/esphome/esphome.git
synced 2026-05-22 18:56:40 +08:00
[script] Fix cpp_unit_test crash for non-MULTI_CONF platform components (#16104)
This commit is contained in:
+54
-25
@@ -57,6 +57,59 @@ def hash_components(components: list[str]) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def populate_dependency_config(
|
||||
config: dict,
|
||||
component_names: list[str],
|
||||
*,
|
||||
get_component_fn: Callable[[str], object | None] = get_component,
|
||||
register_platform_fn: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Populate ``config`` with empty entries for transitive dependencies.
|
||||
|
||||
For every name in ``component_names``:
|
||||
|
||||
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends
|
||||
``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
|
||||
* Bare components are looked up via ``get_component_fn``. Platform
|
||||
components (``IS_PLATFORM_COMPONENT``) 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
|
||||
``domain.platform`` entry follows, because the ``domain.platform`` branch
|
||||
does ``config.setdefault(domain, []).append(...)`` and would crash on a
|
||||
leftover dict.
|
||||
"""
|
||||
if register_platform_fn is None:
|
||||
register_platform_fn = CORE.testing_ensure_platform_registered
|
||||
for component_name in component_names:
|
||||
if "." in component_name:
|
||||
domain, component = component_name.split(".", maxsplit=1)
|
||||
domain_list = config.setdefault(domain, [])
|
||||
register_platform_fn(domain)
|
||||
domain_list.append({CONF_PLATFORM: component})
|
||||
continue
|
||||
# Skip "core" — it's a pseudo-component handled by the build
|
||||
# system, not a real loadable component (get_component returns None)
|
||||
component = get_component_fn(component_name)
|
||||
if component is None:
|
||||
continue
|
||||
if component.multi_conf or component.is_platform_component:
|
||||
config.setdefault(component_name, [])
|
||||
elif component_name not in config:
|
||||
schema = component.config_schema
|
||||
try:
|
||||
config[component_name] = schema({}) if schema is not None else {}
|
||||
except Exception: # noqa: BLE001
|
||||
# Schema requires explicit input we can't synthesize; fall
|
||||
# back to an empty mapping so subscripting at least returns
|
||||
# KeyError on missing keys rather than crashing on the
|
||||
# wrong type.
|
||||
config[component_name] = {}
|
||||
|
||||
|
||||
def filter_components_with_files(components: list[str], tests_dir: Path) -> list[str]:
|
||||
"""Filter out components that do not have .cpp or .h files in the tests dir.
|
||||
|
||||
@@ -316,31 +369,7 @@ def compile_and_get_binary(
|
||||
|
||||
# Add remaining components and dependencies to the configuration after
|
||||
# validation, so their source files are included in the build.
|
||||
for component_name in components_with_dependencies:
|
||||
if "." in component_name:
|
||||
domain, component = component_name.split(".", maxsplit=1)
|
||||
domain_list = config.setdefault(domain, [])
|
||||
CORE.testing_ensure_platform_registered(domain)
|
||||
domain_list.append({CONF_PLATFORM: component})
|
||||
# Skip "core" — it's a pseudo-component handled by the build
|
||||
# system, not a real loadable component (get_component returns None)
|
||||
elif (component := get_component(component_name)) is not None:
|
||||
# MULTI_CONF components store their config as a list of dicts,
|
||||
# everything else stores a single dict. Run the component's
|
||||
# schema with {} so defaults get populated -- code paths like
|
||||
# socket.FILTER_SOURCE_FILES expect a fully-populated mapping.
|
||||
if component.multi_conf:
|
||||
config.setdefault(component_name, [])
|
||||
elif component_name not in config:
|
||||
schema = component.config_schema
|
||||
try:
|
||||
config[component_name] = schema({}) if schema is not None else {}
|
||||
except Exception: # noqa: BLE001
|
||||
# Schema requires explicit input we can't synthesize; fall
|
||||
# back to an empty mapping so subscripting at least returns
|
||||
# KeyError on missing keys rather than crashing on the
|
||||
# wrong type.
|
||||
config[component_name] = {}
|
||||
populate_dependency_config(config, components_with_dependencies)
|
||||
|
||||
# Register platforms from the extra config (benchmark.yaml) so
|
||||
# USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing
|
||||
|
||||
@@ -258,3 +258,161 @@ def test_load_wraps_platform_component(tmp_path: Path) -> None:
|
||||
assert key == "bthome.sensor"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
assert installed.to_code is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# populate_dependency_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_component_stub(
|
||||
*,
|
||||
multi_conf: bool = False,
|
||||
is_platform_component: bool = False,
|
||||
config_schema=None,
|
||||
) -> MagicMock:
|
||||
stub = MagicMock()
|
||||
stub.multi_conf = multi_conf
|
||||
stub.is_platform_component = is_platform_component
|
||||
stub.config_schema = config_schema
|
||||
return stub
|
||||
|
||||
|
||||
def test_populate_platform_component_listed_alone_uses_list() -> None:
|
||||
"""Regression: a platform component (sensor) with no `sensor.x` siblings
|
||||
must land as `[]` in config. Previously it was populated as a dict via
|
||||
`schema({})`, which then crashed the sibling `domain.platform` branch
|
||||
when later dependencies tried `config.setdefault('sensor', []).append(...)`.
|
||||
"""
|
||||
sensor = _make_component_stub(is_platform_component=True)
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["sensor"],
|
||||
get_component_fn=lambda name: sensor if name == "sensor" else None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
assert config["sensor"] == []
|
||||
|
||||
|
||||
def test_populate_platform_component_then_platform_entry() -> None:
|
||||
"""When `sensor` is processed before `sensor.gpio` (sorted order),
|
||||
the bare-component branch must leave `config['sensor']` as a list so
|
||||
the platform-entry branch can append into it.
|
||||
"""
|
||||
sensor = _make_component_stub(is_platform_component=True)
|
||||
gpio = _make_component_stub() # the bare `gpio` component
|
||||
components: dict[str, object] = {"sensor": sensor, "gpio": gpio}
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["gpio", "sensor", "sensor.gpio"],
|
||||
get_component_fn=components.get,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
assert config["sensor"] == [{"platform": "gpio"}]
|
||||
|
||||
|
||||
def test_populate_multi_conf_component_uses_list() -> None:
|
||||
multi = _make_component_stub(multi_conf=True)
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["multi"],
|
||||
get_component_fn=lambda name: multi if name == "multi" else None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
assert config["multi"] == []
|
||||
|
||||
|
||||
def test_populate_plain_component_uses_schema_defaults() -> None:
|
||||
schema = MagicMock(return_value={"default_key": 42})
|
||||
plain = _make_component_stub(config_schema=schema)
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["plain"],
|
||||
get_component_fn=lambda name: plain if name == "plain" else None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
schema.assert_called_once_with({})
|
||||
assert config["plain"] == {"default_key": 42}
|
||||
|
||||
|
||||
def test_populate_plain_component_falls_back_when_schema_raises() -> None:
|
||||
def picky_schema(_):
|
||||
raise ValueError("required field missing")
|
||||
|
||||
plain = _make_component_stub(config_schema=picky_schema)
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["plain"],
|
||||
get_component_fn=lambda name: plain if name == "plain" else None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
assert config["plain"] == {}
|
||||
|
||||
|
||||
def test_populate_skips_unresolvable_pseudo_components() -> None:
|
||||
"""`core` and other names that get_component returns None for are skipped
|
||||
silently without inserting anything into the config.
|
||||
"""
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["core"],
|
||||
get_component_fn=lambda _: None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
assert config == {}
|
||||
|
||||
|
||||
def test_populate_preserves_existing_plain_component_config() -> None:
|
||||
"""If a plain component already has a config entry (e.g. from the user's
|
||||
YAML), the schema-defaults branch must not overwrite it.
|
||||
"""
|
||||
schema = MagicMock()
|
||||
plain = _make_component_stub(config_schema=schema)
|
||||
config: dict = {"plain": {"user_key": "set_by_user"}}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["plain"],
|
||||
get_component_fn=lambda name: plain if name == "plain" else None,
|
||||
register_platform_fn=lambda _: None,
|
||||
)
|
||||
|
||||
schema.assert_not_called()
|
||||
assert config["plain"] == {"user_key": "set_by_user"}
|
||||
|
||||
|
||||
def test_populate_registers_platform_for_platform_entry() -> None:
|
||||
"""Each `domain.platform` entry triggers register_platform_fn(domain) so
|
||||
USE_<DOMAIN> defines get emitted later in the build pipeline.
|
||||
"""
|
||||
registered: list[str] = []
|
||||
config: dict = {}
|
||||
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["sensor.gpio", "binary_sensor.gpio"],
|
||||
get_component_fn=lambda _: None,
|
||||
register_platform_fn=registered.append,
|
||||
)
|
||||
|
||||
assert registered == ["sensor", "binary_sensor"]
|
||||
assert config["sensor"] == [{"platform": "gpio"}]
|
||||
assert config["binary_sensor"] == [{"platform": "gpio"}]
|
||||
|
||||
Reference in New Issue
Block a user