diff --git a/script/build_helpers.py b/script/build_helpers.py index 4cf2f93fbb..0e0e8170a0 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -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: }`` 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 diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 467940fc33..3149712563 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -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_ 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"}]