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:
parent
a253991c6d
commit
c8cb0ff61d
9 changed files with 135 additions and 16 deletions
|
@ -1432,22 +1432,24 @@ async def async_process_component_config( # noqa: C901
|
||||||
|
|
||||||
# Check if the integration has a custom config validator
|
# Check if the integration has a custom config validator
|
||||||
config_validator = None
|
config_validator = None
|
||||||
try:
|
if integration.platform_exists("config") is not False:
|
||||||
config_validator = await integration.async_get_platform("config")
|
# If the config platform cannot possibly exist, don't try to load it.
|
||||||
except ImportError as err:
|
try:
|
||||||
# Filter out import error of the config platform.
|
config_validator = await integration.async_get_platform("config")
|
||||||
# If the config platform contains bad imports, make sure
|
except ImportError as err:
|
||||||
# that still fails.
|
# Filter out import error of the config platform.
|
||||||
if err.name != f"{integration.pkg_path}.config":
|
# If the config platform contains bad imports, make sure
|
||||||
exc_info = ConfigExceptionInfo(
|
# that still fails.
|
||||||
err,
|
if err.name != f"{integration.pkg_path}.config":
|
||||||
ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR,
|
exc_info = ConfigExceptionInfo(
|
||||||
domain,
|
err,
|
||||||
config,
|
ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR,
|
||||||
integration_docs,
|
domain,
|
||||||
)
|
config,
|
||||||
config_exceptions.append(exc_info)
|
integration_docs,
|
||||||
return IntegrationConfigInfo(None, config_exceptions)
|
)
|
||||||
|
config_exceptions.append(exc_info)
|
||||||
|
return IntegrationConfigInfo(None, config_exceptions)
|
||||||
|
|
||||||
if config_validator is not None and hasattr(
|
if config_validator is not None and hasattr(
|
||||||
config_validator, "async_validate_config"
|
config_validator, "async_validate_config"
|
||||||
|
|
|
@ -48,6 +48,10 @@ def _get_platform(
|
||||||
)
|
)
|
||||||
return None
|
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:
|
try:
|
||||||
return integration.get_platform(platform_name)
|
return integration.get_platform(platform_name)
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
|
|
|
@ -12,6 +12,7 @@ from dataclasses import dataclass
|
||||||
import functools as ft
|
import functools as ft
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
@ -976,6 +977,43 @@ class Integration:
|
||||||
return platform
|
return platform
|
||||||
return self._load_platform(platform_name)
|
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:
|
def _load_platform(self, platform_name: str) -> ModuleType:
|
||||||
"""Load a platform for an integration.
|
"""Load a platform for an integration.
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Test to verify that we can load components."""
|
"""Test to verify that we can load components."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
@ -1193,3 +1194,45 @@ async def test_async_get_platform_raises_after_import_failure(
|
||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
assert "loaded_executor=False" not 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
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Config flow."""
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||||
|
return True
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""Constants for test_package custom component."""
|
||||||
|
TEST = 5
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Group."""
|
||||||
|
|
||||||
|
MAGIC = 1
|
|
@ -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"
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue