Avoid trying to import platforms that do not exist (#112028)

* Avoid trying to import platforms that do not exist

* adjust

* fixes

* cleanup

* cleanup

* cleanup

* Apply suggestions from code review

* docs

* fixes

* fixes

* comment

* coverage

* coverage

* coverage

* Switch config to use async_get_component

This was another path where integrations that were marked to load in the executor
would be loaded in the loop

* Switch config to use async_get_component/async_get_platform

This was another path where integrations that were marked to load in the executor
would be loaded in the loop

* merge

* refactor

* refactor

* coverage

* preen

* preen
This commit is contained in:
J. Nick Koston 2024-03-02 17:14:28 -10:00 committed by GitHub
parent a253991c6d
commit c8cb0ff61d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 135 additions and 16 deletions

View File

@ -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"

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
"""Config flow."""
from homeassistant.core import HomeAssistant
async def _async_has_devices(hass: HomeAssistant) -> bool:
return True

View File

@ -0,0 +1,2 @@
"""Constants for test_package custom component."""
TEST = 5

View File

@ -0,0 +1,3 @@
"""Group."""
MAGIC = 1

View File

@ -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"
}