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,6 +1432,8 @@ 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
if integration.platform_exists("config") is not False:
# If the config platform cannot possibly exist, don't try to load it.
try: try:
config_validator = await integration.async_get_platform("config") config_validator = await integration.async_get_platform("config")
except ImportError as err: except ImportError as err:

View file

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

View file

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

View file

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

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