diff --git a/homeassistant/config.py b/homeassistant/config.py index d52346e9299..36ac3843b3a 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1432,22 +1432,24 @@ async def async_process_component_config( # noqa: C901 # Check if the integration has a custom config validator config_validator = None - try: - config_validator = await integration.async_get_platform("config") - except ImportError as err: - # Filter out import error of the config platform. - # If the config platform contains bad imports, make sure - # that still fails. - if err.name != f"{integration.pkg_path}.config": - exc_info = ConfigExceptionInfo( - err, - ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, - domain, - config, - integration_docs, - ) - config_exceptions.append(exc_info) - return IntegrationConfigInfo(None, config_exceptions) + if integration.platform_exists("config") is not False: + # If the config platform cannot possibly exist, don't try to load it. + try: + config_validator = await integration.async_get_platform("config") + except ImportError as err: + # Filter out import error of the config platform. + # If the config platform contains bad imports, make sure + # that still fails. + if err.name != f"{integration.pkg_path}.config": + exc_info = ConfigExceptionInfo( + err, + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) if config_validator is not None and hasattr( config_validator, "async_validate_config" diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 138722bd455..aebbc854693 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -48,6 +48,10 @@ def _get_platform( ) return None + if integration.platform_exists(platform_name) is False: + # If the platform cannot possibly exist, don't bother trying to load it + return None + try: return integration.get_platform(platform_name) except ImportError as err: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 8b3316b1a7f..7873fbd4c74 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -12,6 +12,7 @@ from dataclasses import dataclass import functools as ft import importlib import logging +import os import pathlib import sys import time @@ -976,6 +977,43 @@ class Integration: return platform return self._load_platform(platform_name) + def platform_exists(self, platform_name: str) -> bool | None: + """Check if a platform exists for an integration. + + Returns True if the platform exists, False if it does not. + + If it cannot be determined if the platform exists without attempting + to import the component, it returns None. This will only happen + if this function is called before get_component or async_get_component + has been called for the integration or the integration failed to load. + """ + full_name = f"{self.domain}.{platform_name}" + + cache: dict[str, ModuleType] = self.hass.data[DATA_COMPONENTS] + if full_name in cache: + return True + + missing_platforms_cache: dict[str, ImportError] + missing_platforms_cache = self.hass.data[DATA_MISSING_PLATFORMS] + if full_name in missing_platforms_cache: + return False + + if not (component := cache.get(self.domain)) or not ( + file := getattr(component, "__file__", None) + ): + return None + + path: pathlib.Path = pathlib.Path(file).parent.joinpath(platform_name) + if os.path.exists(path.with_suffix(".py")) or os.path.exists(path): + return True + + exc = ModuleNotFoundError( + f"Platform {full_name} not found", + name=f"{self.pkg_path}.{platform_name}", + ) + missing_platforms_cache[full_name] = exc + return False + def _load_platform(self, platform_name: str) -> ModuleType: """Load a platform for an integration. diff --git a/tests/test_loader.py b/tests/test_loader.py index b9a65438728..34104987de4 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,5 +1,6 @@ """Test to verify that we can load components.""" import asyncio +import os import sys from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -1193,3 +1194,45 @@ async def test_async_get_platform_raises_after_import_failure( in caplog.text ) assert "loaded_executor=False" not in caplog.text + + +async def test_platform_exists( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test platform_exists.""" + integration = await loader.async_get_integration(hass, "test_integration_platform") + assert integration.domain == "test_integration_platform" + + # get_component never called, will return None + assert integration.platform_exists("non_existing") is None + + component = integration.get_component() + assert component.DOMAIN == "test_integration_platform" + + # component is loaded, should now return False + with patch( + "homeassistant.loader.os.path.exists", wraps=os.path.exists + ) as mock_exists: + assert integration.platform_exists("non_existing") is False + + # We should check if the file exists + assert mock_exists.call_count == 2 + + # component is loaded, should now return False + with patch( + "homeassistant.loader.os.path.exists", wraps=os.path.exists + ) as mock_exists: + assert integration.platform_exists("non_existing") is False + + # We should remember the file does not exist + assert mock_exists.call_count == 0 + + assert integration.platform_exists("group") is True + + platform = await integration.async_get_platform("group") + assert platform.MAGIC == 1 + + platform = integration.get_platform("group") + assert platform.MAGIC == 1 + + assert integration.platform_exists("group") is True diff --git a/tests/testing_config/custom_components/test_integration_platform/__init__.py b/tests/testing_config/custom_components/test_integration_platform/__init__.py new file mode 100644 index 00000000000..6b70949231b --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_platform/__init__.py @@ -0,0 +1,9 @@ +"""Provide a mock package component.""" +from .const import TEST # noqa: F401 + +DOMAIN = "test_integration_platform" + + +async def async_setup(hass, config): + """Mock a successful setup.""" + return True diff --git a/tests/testing_config/custom_components/test_integration_platform/config_flow.py b/tests/testing_config/custom_components/test_integration_platform/config_flow.py new file mode 100644 index 00000000000..9153b666828 --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_platform/config_flow.py @@ -0,0 +1,7 @@ +"""Config flow.""" + +from homeassistant.core import HomeAssistant + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + return True diff --git a/tests/testing_config/custom_components/test_integration_platform/const.py b/tests/testing_config/custom_components/test_integration_platform/const.py new file mode 100644 index 00000000000..7e13e04cb47 --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_platform/const.py @@ -0,0 +1,2 @@ +"""Constants for test_package custom component.""" +TEST = 5 diff --git a/tests/testing_config/custom_components/test_integration_platform/group.py b/tests/testing_config/custom_components/test_integration_platform/group.py new file mode 100644 index 00000000000..070cfa0e406 --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_platform/group.py @@ -0,0 +1,3 @@ +"""Group.""" + +MAGIC = 1 diff --git a/tests/testing_config/custom_components/test_integration_platform/manifest.json b/tests/testing_config/custom_components/test_integration_platform/manifest.json new file mode 100644 index 00000000000..74aa8bb379d --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_platform/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "test_integration_platform", + "name": "Test Integration Platform", + "documentation": "http://test-package.io", + "requirements": [], + "dependencies": [], + "codeowners": [], + "config_flow": true, + "import_executor": true, + "version": "1.2.3" +}