From a1b29b0d0bb6d4e751e42ed1517255c66dfb0ef1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:56:57 -0400 Subject: [PATCH] [external_components] Log when a source overrides built-in components (#18805) --- .../external_components/__init__.py | 34 ++++- .../external_components/test_init.py | 116 +++++++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index c892ec1112..504b1ae679 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -71,6 +71,33 @@ def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> P return components_dir +def _log_overridden_components( + conf: dict[str, Any], component_names: list[str] +) -> None: + overridden = [ + name + for name in component_names + if (loader.CORE_COMPONENTS_PATH / name / "__init__.py").is_file() + ] + if not overridden: + return + if conf[CONF_TYPE] == TYPE_GIT: + source = conf[CONF_URL] + if ref := conf.get(CONF_REF): + source = f"{source}@{ref}" + if path := conf.get(CONF_PATH): + source = f"{source} ({path})" + else: + source = conf[CONF_PATH] + _LOGGER.info( + "External components are overriding built-in components:\n" + " source: %s\n" + " components: %s", + source, + ", ".join(sorted(overridden)), + ) + + def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: @@ -84,8 +111,8 @@ def _process_single_config(config: dict[str, Any]) -> None: raise NotImplementedError if config[CONF_COMPONENTS] == "all": - num_components = len(list(components_dir.glob("*/__init__.py"))) - if num_components > 100: + component_names = [p.parent.name for p in components_dir.glob("*/__init__.py")] + if len(component_names) > 100: # Prevent accidentally including all components from an esphome fork/branch # In this case force the user to manually specify which components they want to include raise cv.Invalid( @@ -102,6 +129,9 @@ def _process_single_config(config: dict[str, Any]) -> None: [CONF_COMPONENTS, i], ) allowed_components = config[CONF_COMPONENTS] + component_names = allowed_components + + _log_overridden_components(conf, component_names) loader.install_meta_finder(components_dir, allowed_components=allowed_components) diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index d3813ecc75..78cb32af54 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -1,16 +1,21 @@ -"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update.""" +"""Tests for the external_components config pass.""" +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock +import pytest + from esphome.components.external_components import do_external_components_pass from esphome.const import ( CONF_EXTERNAL_COMPONENTS, + CONF_PATH, CONF_REFRESH, CONF_SOURCE, CONF_URL, TYPE_GIT, + TYPE_LOCAL, ) from esphome.core import CORE, TimePeriodSeconds @@ -69,3 +74,112 @@ def test_external_components_normal_refresh( mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_logs_built_in_override( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source that provides a component with the same name as a built-in one logs an info message.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + for name in ("gpio", "some_custom_component"): + component_dir = tmp_path / "components" / name + component_dir.mkdir() + (component_dir / "__init__.py").write_text("# Test component") + + with caplog.at_level(logging.INFO): + do_external_components_pass(config) + + assert ( + "External components are overriding built-in components:\n" + " source: https://github.com/test/components\n" + " components: gpio" in caplog.text + ) + assert "some_custom_component" not in caplog.text + + +def test_external_components_override_log_includes_ref( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A git source with a ref logs the ref appended to the url.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main" + + component_dir = tmp_path / "components" / "gpio" + component_dir.mkdir() + (component_dir / "__init__.py").write_text("# Test component") + + with caplog.at_level(logging.INFO): + do_external_components_pass(config) + + assert " source: https://github.com/test/components.git@main\n" in caplog.text + + +def test_external_components_override_log_includes_git_path( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A git source with a subdirectory path logs the path after the url.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components" + + component_dir = tmp_path / "components" / "gpio" + component_dir.mkdir() + (component_dir / "__init__.py").write_text("# Test component") + + with caplog.at_level(logging.INFO): + do_external_components_pass(config) + + assert " source: https://github.com/test/components (components)\n" in caplog.text + + +def test_external_components_override_log_local_source( + tmp_path: Path, + mock_install_meta_finder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A local source logs its resolved path.""" + components_dir = tmp_path / "my_components" + gpio_dir = components_dir / "gpio" + gpio_dir.mkdir(parents=True) + (gpio_dir / "__init__.py").write_text("# Test component") + + CORE.config_path = tmp_path / "dummy.yaml" + config = { + CONF_EXTERNAL_COMPONENTS: [ + {CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}} + ] + } + + with caplog.at_level(logging.INFO): + do_external_components_pass(config) + + assert f" source: {components_dir}\n" in caplog.text + assert " components: gpio" in caplog.text + + +def test_external_components_no_override_no_log( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source that only provides components not shipped with ESPHome logs nothing.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + with caplog.at_level(logging.INFO): + do_external_components_pass(config) + + assert "are overriding built-in components" not in caplog.text