Improve dependencies resolution (#138502)

* Improve dependencies resolution

* Improve tests

* Better docstrings

* Fix comment

* Improve tests

* Improve logging

* Address feedback

* Address feedback

* Address feedback

* Address feedback

* Address feedback

* Simplify error handling

* small log change

* Add comment

* Address feedback

* shorter comments

* Add test
This commit is contained in:
Artur Pragacz 2025-03-11 10:12:23 +01:00 committed by GitHub
parent 52408e67b2
commit 4f25296c50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 398 additions and 247 deletions

View File

@ -93,6 +93,7 @@ from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager from .helpers.storage import get_internal_store_manager
from .helpers.system_info import async_get_system_info from .helpers.system_info import async_get_system_info
from .helpers.typing import ConfigType from .helpers.typing import ConfigType
from .loader import Integration
from .setup import ( from .setup import (
# _setup_started is marked as protected to make it clear # _setup_started is marked as protected to make it clear
# that it is not part of the public API and should not be used # that it is not part of the public API and should not be used
@ -711,20 +712,25 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
return domains return domains
async def _async_resolve_domains_to_setup( async def _async_resolve_domains_and_preload(
hass: core.HomeAssistant, config: dict[str, Any] hass: core.HomeAssistant, config: dict[str, Any]
) -> tuple[set[str], dict[str, loader.Integration]]: ) -> tuple[dict[str, Integration], dict[str, Integration]]:
"""Resolve all dependencies and return list of domains to set up.""" """Resolve all dependencies and return integrations to set up.
The return value is a tuple of two dictionaries:
- The first dictionary contains integrations
specified by the configuration (including config entries).
- The second dictionary contains the same integrations as the first dictionary
together with all their dependencies.
"""
domains_to_setup = _get_domains(hass, config) domains_to_setup = _get_domains(hass, config)
needed_requirements: set[str] = set()
platform_integrations = conf_util.extract_platform_integrations( platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS config, BASE_PLATFORMS
) )
# Ensure base platforms that have platform integrations are added to # Ensure base platforms that have platform integrations are added to `domains`,
# to `domains_to_setup so they can be setup first instead of # so they can be setup first instead of discovering them later when a config
# discovering them when later when a config entry setup task # entry setup task notices that it's needed and there is already a long line
# notices its needed and there is already a long line to use # to use the import executor.
# the import executor.
# #
# For example if we have # For example if we have
# sensor: # sensor:
@ -740,111 +746,78 @@ async def _async_resolve_domains_to_setup(
# so this will be less of a problem in the future. # so this will be less of a problem in the future.
domains_to_setup.update(platform_integrations) domains_to_setup.update(platform_integrations)
# Load manifests for base platforms and platform based integrations # Additionally process base platforms since we do not require the manifest
# that are defined under base platforms right away since we do not require # to list them as dependencies.
# the manifest to list them as dependencies and we want to avoid the lock # We want to later avoid lock contention when multiple integrations try to load
# contention when multiple integrations try to load them at once # their manifests at once.
additional_manifests_to_load = { # Also process integrations that are defined under base platforms
# to speed things up.
additional_domains_to_process = {
*BASE_PLATFORMS, *BASE_PLATFORMS,
*chain.from_iterable(platform_integrations.values()), *chain.from_iterable(platform_integrations.values()),
} }
translations_to_load = additional_manifests_to_load.copy()
# Resolve all dependencies so we know all integrations # Resolve all dependencies so we know all integrations
# that will have to be loaded and start right-away # that will have to be loaded and start right-away
integration_cache: dict[str, loader.Integration] = {} integrations_or_excs = await loader.async_get_integrations(
to_resolve: set[str] = domains_to_setup hass, {*domains_to_setup, *additional_domains_to_process}
while to_resolve or additional_manifests_to_load: )
old_to_resolve: set[str] = to_resolve # Eliminate those missing or with invalid manifest
to_resolve = set() integrations_to_process = {
domain: itg
for domain, itg in integrations_or_excs.items()
if isinstance(itg, Integration)
}
integrations_dependencies = await loader.resolve_integrations_dependencies(
hass, integrations_to_process.values()
)
# Eliminate those without valid dependencies
integrations_to_process = {
domain: integrations_to_process[domain] for domain in integrations_dependencies
}
if additional_manifests_to_load: integrations_to_setup = {
to_get = {*old_to_resolve, *additional_manifests_to_load} domain: itg
additional_manifests_to_load.clear() for domain, itg in integrations_to_process.items()
else: if domain in domains_to_setup
to_get = old_to_resolve }
all_integrations_to_setup = integrations_to_setup.copy()
all_integrations_to_setup.update(
(dep, loader.async_get_loaded_integration(hass, dep))
for domain in integrations_to_setup
for dep in integrations_dependencies[domain].difference(
all_integrations_to_setup
)
)
manifest_deps: set[str] = set() # Gather requirements for all integrations,
resolve_dependencies_tasks: list[asyncio.Task[bool]] = [] # their dependencies and after dependencies.
integrations_to_process: list[loader.Integration] = [] # To gather all the requirements we must ignore exceptions here.
# The exceptions will be detected and handled later in the bootstrap process.
for domain, itg in (await loader.async_get_integrations(hass, to_get)).items(): integrations_after_dependencies = (
if not isinstance(itg, loader.Integration): await loader.resolve_integrations_after_dependencies(
continue hass, integrations_to_process.values(), ignore_exceptions=True
integration_cache[domain] = itg )
needed_requirements.update(itg.requirements) )
integrations_requirements = {
# Make sure manifests for dependencies are loaded in the next domain: itg.requirements for domain, itg in integrations_to_process.items()
# loop to try to group as many as manifest loads in a single }
# call to avoid the creating one-off executor jobs later in integrations_requirements.update(
# the setup process (dep, loader.async_get_loaded_integration(hass, dep).requirements)
additional_manifests_to_load.update( for deps in integrations_after_dependencies.values()
dep for dep in deps.difference(integrations_requirements)
for dep in chain(itg.dependencies, itg.after_dependencies) )
if dep not in integration_cache all_requirements = set(chain.from_iterable(integrations_requirements.values()))
)
if domain not in old_to_resolve:
continue
integrations_to_process.append(itg)
manifest_deps.update(itg.dependencies)
manifest_deps.update(itg.after_dependencies)
if not itg.all_dependencies_resolved:
resolve_dependencies_tasks.append(
create_eager_task(
itg.resolve_dependencies(),
name=f"resolve dependencies {domain}",
loop=hass.loop,
)
)
if unseen_deps := manifest_deps - integration_cache.keys():
# If there are dependencies, try to preload all
# the integrations manifest at once and add them
# to the list of requirements we need to install
# so we can try to check if they are already installed
# in a single call below which avoids each integration
# having to wait for the lock to do it individually
deps = await loader.async_get_integrations(hass, unseen_deps)
for dependant_domain, dependant_itg in deps.items():
if isinstance(dependant_itg, loader.Integration):
integration_cache[dependant_domain] = dependant_itg
needed_requirements.update(dependant_itg.requirements)
if resolve_dependencies_tasks:
await asyncio.gather(*resolve_dependencies_tasks)
for itg in integrations_to_process:
try:
all_deps = itg.all_dependencies
except RuntimeError:
# Integration.all_dependencies raises RuntimeError if
# dependencies could not be resolved
continue
for dep in all_deps:
if dep in domains_to_setup:
continue
domains_to_setup.add(dep)
to_resolve.add(dep)
_LOGGER.info("Domains to be set up: %s", domains_to_setup)
# Optimistically check if requirements are already installed # Optimistically check if requirements are already installed
# ahead of setting up the integrations so we can prime the cache # ahead of setting up the integrations so we can prime the cache
# We do not wait for this since its an optimization only # We do not wait for this since it's an optimization only
hass.async_create_background_task( hass.async_create_background_task(
requirements.async_load_installed_versions(hass, needed_requirements), requirements.async_load_installed_versions(hass, all_requirements),
"check installed requirements", "check installed requirements",
eager_start=True, eager_start=True,
) )
#
# Only add the domains_to_setup after we finish resolving
# as new domains are likely to added in the process
#
translations_to_load.update(domains_to_setup)
# Start loading translations for all integrations we are going to set up # Start loading translations for all integrations we are going to set up
# in the background so they are ready when we need them. This avoids a # in the background so they are ready when we need them. This avoids a
# lot of waiting for the translation load lock and a thundering herd of # lot of waiting for the translation load lock and a thundering herd of
@ -855,6 +828,7 @@ async def _async_resolve_domains_to_setup(
# hold the translation load lock and if anything is fast enough to # hold the translation load lock and if anything is fast enough to
# wait for the translation load lock, loading will be done by the # wait for the translation load lock, loading will be done by the
# time it gets to it. # time it gets to it.
translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process}
hass.async_create_background_task( hass.async_create_background_task(
translation.async_load_integrations(hass, translations_to_load), translation.async_load_integrations(hass, translations_to_load),
"load translations", "load translations",
@ -866,13 +840,13 @@ async def _async_resolve_domains_to_setup(
# in the setup process. # in the setup process.
hass.async_create_background_task( hass.async_create_background_task(
get_internal_store_manager(hass).async_preload( get_internal_store_manager(hass).async_preload(
[*PRELOAD_STORAGE, *domains_to_setup] [*PRELOAD_STORAGE, *all_integrations_to_setup]
), ),
"preload storage", "preload storage",
eager_start=True, eager_start=True,
) )
return domains_to_setup, integration_cache return integrations_to_setup, all_integrations_to_setup
async def _async_set_up_integrations( async def _async_set_up_integrations(
@ -882,69 +856,90 @@ async def _async_set_up_integrations(
watcher = _WatchPendingSetups(hass, _setup_started(hass)) watcher = _WatchPendingSetups(hass, _setup_started(hass))
watcher.async_start() watcher.async_start()
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config hass, config
) )
stage_2_domains = domains_to_setup.copy() all_domains = set(all_integrations)
domains = set(integrations)
_LOGGER.info(
"Domains to be set up: %s | %s",
domains,
all_domains - domains,
)
# Initialize recorder # Initialize recorder
if "recorder" in domains_to_setup: if "recorder" in all_domains:
recorder.async_initialize_recorder(hass) recorder.async_initialize_recorder(hass)
# Initialize backup # Initialize backup
if "backup" in domains_to_setup: if "backup" in all_domains:
backup.async_initialize_backup(hass) backup.async_initialize_backup(hass)
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ stages: list[tuple[str, set[str], int | None]] = [
*( *(
(name, domain_group & domains_to_setup, timeout) (name, domain_group, timeout)
for name, domain_group, timeout in STAGE_0_INTEGRATIONS for name, domain_group, timeout in STAGE_0_INTEGRATIONS
), ),
("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), ("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT),
("2", domains, STAGE_2_TIMEOUT),
] ]
_LOGGER.info("Setting up stage 0 and 1") _LOGGER.info("Setting up stage 0")
for name, domain_group, timeout in stage_0_and_1_domains: for name, domain_group, timeout in stages:
if not domain_group: stage_domains_unfiltered = domain_group & all_domains
if not stage_domains_unfiltered:
_LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group)
continue continue
_LOGGER.info("Setting up %s: %s", name, domain_group) stage_domains = stage_domains_unfiltered - hass.config.components
to_be_loaded = domain_group.copy() if not stage_domains:
to_be_loaded.update( _LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered)
continue
stage_dep_domains_unfiltered = {
dep dep
for domain in domain_group for domain in stage_domains
if (integration := integration_cache.get(domain)) is not None for dep in all_integrations[domain].all_dependencies
for dep in integration.all_dependencies if dep not in stage_domains
}
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains
stage_all_integrations = {
domain: all_integrations[domain] for domain in stage_all_domains
}
# Detect all cycles
stage_integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, stage_all_integrations.values(), stage_all_domains
)
) )
async_set_domains_to_be_loaded(hass, to_be_loaded) stage_all_domains = set(stage_integrations_after_dependencies)
stage_2_domains -= to_be_loaded stage_domains &= stage_all_domains
stage_dep_domains &= stage_all_domains
_LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
name,
stage_domains,
stage_domains_unfiltered - stage_domains,
stage_dep_domains,
stage_dep_domains_unfiltered - stage_dep_domains,
)
async_set_domains_to_be_loaded(hass, stage_all_domains)
if timeout is None: if timeout is None:
await _async_setup_multi_components(hass, domain_group, config) await _async_setup_multi_components(hass, stage_all_domains, config)
else: continue
try:
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
await _async_setup_multi_components(hass, domain_group, config)
except TimeoutError:
_LOGGER.warning(
"Setup timed out for %s waiting on %s - moving forward",
name,
hass._active_tasks, # noqa: SLF001
)
# Add after dependencies when setting up stage 2 domains
async_set_domains_to_be_loaded(hass, stage_2_domains)
if stage_2_domains:
_LOGGER.info("Setting up stage 2: %s", stage_2_domains)
try: try:
async with hass.timeout.async_timeout( async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME await _async_setup_multi_components(hass, stage_all_domains, config)
):
await _async_setup_multi_components(hass, stage_2_domains, config)
except TimeoutError: except TimeoutError:
_LOGGER.warning( _LOGGER.warning(
"Setup timed out for stage 2 waiting on %s - moving forward", "Setup timed out for stage %s waiting on %s - moving forward",
name,
hass._active_tasks, # noqa: SLF001 hass._active_tasks, # noqa: SLF001
) )
@ -1046,8 +1041,6 @@ async def _async_setup_multi_components(
config: dict[str, Any], config: dict[str, Any],
) -> None: ) -> None:
"""Set up multiple domains. Log on failure.""" """Set up multiple domains. Log on failure."""
# Avoid creating tasks for domains that were setup in a previous stage
domains_not_yet_setup = domains - hass.config.components
# Create setup tasks for base platforms first since everything will have # Create setup tasks for base platforms first since everything will have
# to wait to be imported, and the sooner we can get the base platforms # to wait to be imported, and the sooner we can get the base platforms
# loaded the sooner we can start loading the rest of the integrations. # loaded the sooner we can start loading the rest of the integrations.
@ -1057,9 +1050,7 @@ async def _async_setup_multi_components(
f"setup component {domain}", f"setup component {domain}",
eager_start=True, eager_start=True,
) )
for domain in sorted( for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True)
domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True
)
} }
results = await asyncio.gather(*futures.values(), return_exceptions=True) results = await asyncio.gather(*futures.values(), return_exceptions=True)
for idx, domain in enumerate(futures): for idx, domain in enumerate(futures):

View File

@ -40,6 +40,8 @@ from .generated.ssdp import SSDP
from .generated.usb import USB from .generated.usb import USB
from .generated.zeroconf import HOMEKIT, ZEROCONF from .generated.zeroconf import HOMEKIT, ZEROCONF
from .helpers.json import json_bytes, json_fragment from .helpers.json import json_bytes, json_fragment
from .helpers.typing import UNDEFINED, UndefinedType
from .util.async_ import create_eager_task
from .util.hass_dict import HassKey from .util.hass_dict import HassKey
from .util.json import JSON_DECODE_EXCEPTIONS, json_loads from .util.json import JSON_DECODE_EXCEPTIONS, json_loads
@ -758,10 +760,8 @@ class Integration:
manifest["overwrites_built_in"] = self.overwrites_built_in manifest["overwrites_built_in"] = self.overwrites_built_in
if self.dependencies: if self.dependencies:
self._all_dependencies_resolved: bool | None = None self._all_dependencies: set[str] | Exception | None = None
self._all_dependencies: set[str] | None = None
else: else:
self._all_dependencies_resolved = True
self._all_dependencies = set() self._all_dependencies = set()
self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS] self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS]
@ -933,47 +933,25 @@ class Integration:
"""Return all dependencies including sub-dependencies.""" """Return all dependencies including sub-dependencies."""
if self._all_dependencies is None: if self._all_dependencies is None:
raise RuntimeError("Dependencies not resolved!") raise RuntimeError("Dependencies not resolved!")
if isinstance(self._all_dependencies, Exception):
raise self._all_dependencies
return self._all_dependencies return self._all_dependencies
@property @property
def all_dependencies_resolved(self) -> bool: def all_dependencies_resolved(self) -> bool:
"""Return if all dependencies have been resolved.""" """Return if all dependencies have been resolved."""
return self._all_dependencies_resolved is not None return self._all_dependencies is not None
async def resolve_dependencies(self) -> bool: async def resolve_dependencies(self) -> set[str] | None:
"""Resolve all dependencies.""" """Resolve all dependencies."""
if self._all_dependencies_resolved is not None: if self._all_dependencies is not None:
return self._all_dependencies_resolved if isinstance(self._all_dependencies, Exception):
return None
return self._all_dependencies
self._all_dependencies_resolved = False result = await resolve_integrations_dependencies(self.hass, (self,))
try: return result.get(self.domain)
dependencies = await _async_component_dependencies(self.hass, self)
except IntegrationNotFound as err:
_LOGGER.error(
(
"Unable to resolve dependencies for %s: unable to resolve"
" (sub)dependency %s"
),
self.domain,
err.domain,
)
except CircularDependency as err:
_LOGGER.error(
(
"Unable to resolve dependencies for %s: it contains a circular"
" dependency: %s -> %s"
),
self.domain,
err.from_domain,
err.to_domain,
)
else:
dependencies.discard(self.domain)
self._all_dependencies = dependencies
self._all_dependencies_resolved = True
return self._all_dependencies_resolved
async def async_get_component(self) -> ComponentProtocol: async def async_get_component(self) -> ComponentProtocol:
"""Return the component. """Return the component.
@ -1441,6 +1419,189 @@ async def async_get_integrations(
return results return results
class _ResolveDependenciesCacheProtocol(Protocol):
def get(self, itg: Integration) -> set[str] | Exception | None: ...
def __setitem__(
self, itg: Integration, all_dependencies: set[str] | Exception
) -> None: ...
class _ResolveDependenciesCache(_ResolveDependenciesCacheProtocol):
"""Cache for resolve_integrations_dependencies."""
def get(self, itg: Integration) -> set[str] | Exception | None:
return itg._all_dependencies # noqa: SLF001
def __setitem__(
self, itg: Integration, all_dependencies: set[str] | Exception
) -> None:
itg._all_dependencies = all_dependencies # noqa: SLF001
async def resolve_integrations_dependencies(
hass: HomeAssistant, integrations: Iterable[Integration]
) -> dict[str, set[str]]:
"""Resolve all dependencies for integrations.
Detects circular dependencies and missing integrations.
"""
resolved = _ResolveDependenciesCache()
async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None:
try:
return await _do_resolve_dependencies(itg, cache=resolved)
except Exception as exc: # noqa: BLE001
_LOGGER.error("Unable to resolve dependencies for %s: %s", itg.domain, exc)
return None
resolve_dependencies_tasks = {
itg.domain: create_eager_task(
_resolve_deps_catch_exceptions(itg),
name=f"resolve dependencies {itg.domain}",
loop=hass.loop,
)
for itg in integrations
}
result = await asyncio.gather(*resolve_dependencies_tasks.values())
return {
domain: deps
for domain, deps in zip(resolve_dependencies_tasks, result, strict=True)
if deps is not None
}
async def resolve_integrations_after_dependencies(
hass: HomeAssistant,
integrations: Iterable[Integration],
possible_after_dependencies: set[str] | None = None,
*,
ignore_exceptions: bool = False,
) -> dict[str, set[str]]:
"""Resolve all dependencies, including after_dependencies, for integrations.
Detects circular dependencies and missing integrations.
"""
resolved: dict[Integration, set[str] | Exception] = {}
async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None:
try:
return await _do_resolve_dependencies(
itg,
cache=resolved,
possible_after_dependencies=possible_after_dependencies,
ignore_exceptions=ignore_exceptions,
)
except Exception as exc: # noqa: BLE001
_LOGGER.error(
"Unable to resolve (after) dependencies for %s: %s", itg.domain, exc
)
return None
resolve_dependencies_tasks = {
itg.domain: create_eager_task(
_resolve_deps_catch_exceptions(itg),
name=f"resolve after dependencies {itg.domain}",
loop=hass.loop,
)
for itg in integrations
}
result = await asyncio.gather(*resolve_dependencies_tasks.values())
return {
domain: deps
for domain, deps in zip(resolve_dependencies_tasks, result, strict=True)
if deps is not None
}
async def _do_resolve_dependencies(
itg: Integration,
*,
cache: _ResolveDependenciesCacheProtocol,
possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED,
ignore_exceptions: bool = False,
) -> set[str]:
"""Recursively resolve all dependencies.
Uses `cache` to cache the results.
If `possible_after_dependencies` is not UNDEFINED,
listed after dependencies are also considered.
If `possible_after_dependencies` is None,
all the possible after dependencies are considered.
If `ignore_exceptions` is True, exceptions are caught and ignored
and the normal resolution algorithm continues.
Otherwise, exceptions are raised.
"""
resolved = cache
resolving: set[str] = set()
async def do_resolve_dependencies_impl(itg: Integration) -> set[str]:
domain = itg.domain
# If it's already resolved, no point doing it again.
if (result := resolved.get(itg)) is not None:
if isinstance(result, Exception):
raise result
return result
# If we are already resolving it, we have a circular dependency.
if domain in resolving:
if ignore_exceptions:
resolved[itg] = set()
return set()
exc = CircularDependency([domain])
resolved[itg] = exc
raise exc
resolving.add(domain)
dependencies_domains = set(itg.dependencies)
if possible_after_dependencies is not UNDEFINED:
if possible_after_dependencies is None:
after_dependencies: Iterable[str] = itg.after_dependencies
else:
after_dependencies = (
set(itg.after_dependencies) & possible_after_dependencies
)
dependencies_domains.update(after_dependencies)
dependencies = await async_get_integrations(itg.hass, dependencies_domains)
all_dependencies: set[str] = set()
for dep_domain, dep_integration in dependencies.items():
if isinstance(dep_integration, Exception):
if ignore_exceptions:
continue
resolved[itg] = dep_integration
raise dep_integration
all_dependencies.add(dep_domain)
try:
dep_dependencies = await do_resolve_dependencies_impl(dep_integration)
except CircularDependency as exc:
exc.extend_cycle(domain)
resolved[itg] = exc
raise
except Exception as exc:
resolved[itg] = exc
raise
all_dependencies.update(dep_dependencies)
resolving.remove(domain)
resolved[itg] = all_dependencies
return all_dependencies
return await do_resolve_dependencies_impl(itg)
class LoaderError(Exception): class LoaderError(Exception):
"""Loader base error.""" """Loader base error."""
@ -1466,11 +1627,13 @@ class IntegrationNotLoaded(LoaderError):
class CircularDependency(LoaderError): class CircularDependency(LoaderError):
"""Raised when a circular dependency is found when resolving components.""" """Raised when a circular dependency is found when resolving components."""
def __init__(self, from_domain: str | set[str], to_domain: str) -> None: def __init__(self, domain_cycle: list[str]) -> None:
"""Initialize circular dependency error.""" """Initialize circular dependency error."""
super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") super().__init__("Circular dependency detected", domain_cycle)
self.from_domain = from_domain
self.to_domain = to_domain def extend_cycle(self, domain: str) -> None:
"""Extend the cycle with the domain."""
self.args[1].insert(0, domain)
def _load_file( def _load_file(
@ -1624,50 +1787,6 @@ def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT:
return func return func
async def _async_component_dependencies(
hass: HomeAssistant,
integration: Integration,
) -> set[str]:
"""Get component dependencies."""
loading: set[str] = set()
loaded: set[str] = set()
async def component_dependencies_impl(integration: Integration) -> None:
"""Recursively get component dependencies."""
domain = integration.domain
if not (dependencies := integration.dependencies):
loaded.add(domain)
return
loading.add(domain)
dep_integrations = await async_get_integrations(hass, dependencies)
for dependency_domain, dep_integration in dep_integrations.items():
if isinstance(dep_integration, Exception):
raise dep_integration
# If we are already loading it, we have a circular dependency.
# We have to check it here to make sure that every integration that
# depends on us, does not appear in our own after_dependencies.
if conflict := loading.intersection(dep_integration.after_dependencies):
raise CircularDependency(conflict, dependency_domain)
# If we have already loaded it, no point doing it again.
if dependency_domain in loaded:
continue
# If we are already loading it, we have a circular dependency.
if dependency_domain in loading:
raise CircularDependency(dependency_domain, domain)
await component_dependencies_impl(dep_integration)
loading.remove(domain)
loaded.add(domain)
await component_dependencies_impl(integration)
return loaded
def _async_mount_config_dir(hass: HomeAssistant) -> None: def _async_mount_config_dir(hass: HomeAssistant) -> None:
"""Mount config dir in order to load custom_component. """Mount config dir in order to load custom_component.

View File

@ -323,7 +323,7 @@ async def _async_setup_component(
translation.async_load_integrations(hass, integration_set), loop=hass.loop translation.async_load_integrations(hass, integration_set), loop=hass.loop
) )
# Validate all dependencies exist and there are no circular dependencies # Validate all dependencies exist and there are no circular dependencies
if not await integration.resolve_dependencies(): if await integration.resolve_dependencies() is None:
return False return False
# Process requirements as soon as possible, so we can import the component # Process requirements as soon as possible, so we can import the component

View File

@ -572,7 +572,7 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None:
MockModule( MockModule(
domain="second_dep", domain="second_dep",
async_setup=gen_domain_setup("second_dep"), async_setup=gen_domain_setup("second_dep"),
partial_manifest={"after_dependencies": ["first_dep"]}, partial_manifest={"after_dependencies": ["first_dep", "root"]},
), ),
) )
@ -1169,6 +1169,7 @@ async def test_bootstrap_is_cancellation_safe(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test cancellation during async_setup_component does not cancel bootstrap.""" """Test cancellation during async_setup_component does not cancel bootstrap."""
mock_integration(hass, MockModule(domain="cancel_integration"))
with patch.object( with patch.object(
bootstrap, "async_setup_component", side_effect=asyncio.CancelledError bootstrap, "async_setup_component", side_effect=asyncio.CancelledError
): ):
@ -1185,6 +1186,18 @@ async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
@pytest.mark.parametrize("load_registries", [False])
async def test_bootstrap_log_already_setup_stage(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test logging when all integrations in a stage were already setup."""
with patch.object(bootstrap, "STAGE_1_INTEGRATIONS", {"frontend"}):
await bootstrap._async_set_up_integrations(hass, {})
await hass.async_block_till_done()
assert "Already set up stage 1: {'frontend'}" in caplog.text
@pytest.fixture(name="mock_mqtt_config_flow") @pytest.fixture(name="mock_mqtt_config_flow")
def mock_mqtt_config_flow_fixture() -> Generator[None]: def mock_mqtt_config_flow_fixture() -> Generator[None]:
"""Mock MQTT config flow.""" """Mock MQTT config flow."""

View File

@ -27,33 +27,42 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None:
mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) mock_integration(hass, MockModule("mod2", dependencies=["mod1"]))
mock_integration(hass, MockModule("mod3", dependencies=["mod1"])) mock_integration(hass, MockModule("mod3", dependencies=["mod1"]))
mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"]))
all_domains = {"mod1", "mod2", "mod3", "mod4"}
deps = await loader._async_component_dependencies(hass, mod_4) deps = await loader._do_resolve_dependencies(mod_4, cache={})
assert deps == {"mod1", "mod2", "mod3", "mod4"} assert deps == {"mod1", "mod2", "mod3"}
# Create a circular dependency # Create a circular dependency
mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) mock_integration(hass, MockModule("mod1", dependencies=["mod4"]))
with pytest.raises(loader.CircularDependency): with pytest.raises(loader.CircularDependency):
await loader._async_component_dependencies(hass, mod_4) await loader._do_resolve_dependencies(mod_4, cache={})
# Create a different circular dependency # Create a different circular dependency
mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) mock_integration(hass, MockModule("mod1", dependencies=["mod3"]))
with pytest.raises(loader.CircularDependency): with pytest.raises(loader.CircularDependency):
await loader._async_component_dependencies(hass, mod_4) await loader._do_resolve_dependencies(mod_4, cache={})
# Create a circular after_dependency # Create a circular after_dependency
mock_integration( mock_integration(
hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]})
) )
with pytest.raises(loader.CircularDependency): with pytest.raises(loader.CircularDependency):
await loader._async_component_dependencies(hass, mod_4) await loader._do_resolve_dependencies(
mod_4,
cache={},
possible_after_dependencies=all_domains,
)
# Create a different circular after_dependency # Create a different circular after_dependency
mock_integration( mock_integration(
hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]})
) )
with pytest.raises(loader.CircularDependency): with pytest.raises(loader.CircularDependency):
await loader._async_component_dependencies(hass, mod_4) await loader._do_resolve_dependencies(
mod_4,
cache={},
possible_after_dependencies=all_domains,
)
# Create a circular after_dependency without a hard dependency # Create a circular after_dependency without a hard dependency
mock_integration( mock_integration(
@ -62,29 +71,48 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None:
mod_4 = mock_integration( mod_4 = mock_integration(
hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]})
) )
# this currently doesn't raise, but it should. Will be improved in a follow-up. with pytest.raises(loader.CircularDependency):
await loader._async_component_dependencies(hass, mod_4) await loader._do_resolve_dependencies(
mod_4,
cache={},
possible_after_dependencies=all_domains,
)
result = await loader.resolve_integrations_after_dependencies(hass, (mod_4,))
assert result == {}
result = await loader.resolve_integrations_after_dependencies(
hass, (mod_4,), ignore_exceptions=True
)
assert result["mod4"] == {"mod4", "mod2", "mod1"}
async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None:
"""Test if we can detect nonexistent dependencies of components.""" """Test if we can detect nonexistent dependencies of components."""
mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"]))
with pytest.raises(loader.IntegrationNotFound):
await loader._async_component_dependencies(hass, mod_1)
mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"]))
assert not await mod_2.resolve_dependencies() assert await mod_2.resolve_dependencies() is None
assert mod_2.all_dependencies_resolved assert mod_2.all_dependencies_resolved
with pytest.raises(RuntimeError): with pytest.raises(loader.IntegrationNotFound):
mod_2.all_dependencies # noqa: B018 mod_2.all_dependencies # noqa: B018
# this currently is not resolved, because intermediate results are not cached assert mod_1.all_dependencies_resolved
# this will be improved in a follow-up assert await mod_1.resolve_dependencies() is None
assert not mod_1.all_dependencies_resolved with pytest.raises(loader.IntegrationNotFound):
assert not await mod_1.resolve_dependencies()
with pytest.raises(RuntimeError):
mod_1.all_dependencies # noqa: B018 mod_1.all_dependencies # noqa: B018
result = await loader.resolve_integrations_dependencies(hass, (mod_2, mod_1))
assert result == {}
mod_1 = mock_integration(
hass,
MockModule("mod1", partial_manifest={"after_dependencies": ["non.existent"]}),
)
mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"]))
result = await loader.resolve_integrations_after_dependencies(hass, (mod_2, mod_1))
assert result == {}
def test_component_loader(hass: HomeAssistant) -> None: def test_component_loader(hass: HomeAssistant) -> None:
"""Test loading components.""" """Test loading components."""