ha-core/homeassistant/setup.py

771 lines
26 KiB
Python

"""All methods needed to bootstrap a Home Assistant instance."""
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Awaitable, Callable, Generator, Mapping
import contextlib
import contextvars
from enum import StrEnum
import logging.handlers
import time
from timeit import default_timer as timer
from types import ModuleType
from typing import Any, Final, TypedDict
from . import config as conf_util, core, loader, requirements
from .const import (
EVENT_COMPONENT_LOADED,
EVENT_HOMEASSISTANT_START,
PLATFORM_FORMAT,
Platform,
)
from .core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Event,
HomeAssistant,
callback,
)
from .exceptions import DependencyError, HomeAssistantError
from .helpers import translation
from .helpers.issue_registry import IssueSeverity, async_create_issue
from .helpers.typing import ConfigType
from .util.async_ import create_eager_task
current_setup_group: contextvars.ContextVar[
tuple[str, str | None] | None
] = contextvars.ContextVar("current_setup_group", default=None)
_LOGGER = logging.getLogger(__name__)
ATTR_COMPONENT: Final = "component"
BASE_PLATFORMS = {platform.value for platform in Platform}
# DATA_SETUP is a dict[str, asyncio.Future[bool]], indicating domains which are currently
# being setup or which failed to setup:
# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain
# being setup and the Task is the `_async_setup_component` helper.
# - Tasks are removed from DATA_SETUP if setup was successful, that is,
# the task returned True.
DATA_SETUP = "setup_tasks"
# DATA_SETUP_DONE is a dict [str, asyncio.Future[bool]], indicating components which
# will be setup:
# - Events are added to DATA_SETUP_DONE during bootstrap by
# async_set_domains_to_be_loaded, the key is the domain which will be loaded.
# - Events are set and removed from DATA_SETUP_DONE when async_setup_component
# is finished, regardless of if the setup was successful or not.
DATA_SETUP_DONE = "setup_done"
# DATA_SETUP_STARTED is a dict [tuple[str, str | None], float], indicating when an attempt
# to setup a component started.
DATA_SETUP_STARTED = "setup_started"
# DATA_SETUP_TIME is a defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]
# indicating how time was spent setting up a component and each group (config entry).
DATA_SETUP_TIME = "setup_time"
DATA_DEPS_REQS = "deps_reqs_processed"
DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors"
NOTIFY_FOR_TRANSLATION_KEYS = [
"config_validation_err",
"platform_config_validation_err",
]
SLOW_SETUP_WARNING = 10
SLOW_SETUP_MAX_WAIT = 300
class EventComponentLoaded(TypedDict):
"""EventComponentLoaded data."""
component: str
@callback
def async_notify_setup_error(
hass: HomeAssistant, component: str, display_link: str | None = None
) -> None:
"""Print a persistent notification.
This method must be run in the event loop.
"""
# pylint: disable-next=import-outside-toplevel
from .components import persistent_notification
if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None:
errors = hass.data[DATA_PERSISTENT_ERRORS] = {}
errors[component] = errors.get(component) or display_link
message = "The following integrations and platforms could not be set up:\n\n"
for name, link in errors.items():
show_logs = f"[Show logs](/config/logs?filter={name})"
part = f"[{name}]({link})" if link else name
message += f" - {part} ({show_logs})\n"
message += "\nPlease check your config and [logs](/config/logs)."
persistent_notification.async_create(
hass, message, "Invalid config", "invalid_config"
)
@core.callback
def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) -> None:
"""Set domains that are going to be loaded from the config.
This allow us to:
- Properly handle after_dependencies.
- Keep track of domains which will load but have not yet finished loading
"""
setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP_DONE, {}
)
setup_done_futures.update({domain: hass.loop.create_future() for domain in domains})
def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool:
"""Set up a component and all its dependencies."""
return asyncio.run_coroutine_threadsafe(
async_setup_component(hass, domain, config), hass.loop
).result()
async def async_setup_component(
hass: core.HomeAssistant, domain: str, config: ConfigType
) -> bool:
"""Set up a component and all its dependencies.
This method is a coroutine.
"""
if domain in hass.config.components:
return True
setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP, {}
)
setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP_DONE, {}
)
if existing_setup_future := setup_futures.get(domain):
return await existing_setup_future
setup_future = hass.loop.create_future()
setup_futures[domain] = setup_future
try:
result = await _async_setup_component(hass, domain, config)
setup_future.set_result(result)
if setup_done_future := setup_done_futures.pop(domain, None):
setup_done_future.set_result(result)
return result
except BaseException as err:
futures = [setup_future]
if setup_done_future := setup_done_futures.pop(domain, None):
futures.append(setup_done_future)
for future in futures:
# If the setup call is cancelled it likely means
# Home Assistant is shutting down so the future might
# already be done which will cause this to raise
# an InvalidStateError which is appropriate because
# the component setup was cancelled and is in an
# indeterminate state.
future.set_exception(err)
with contextlib.suppress(BaseException):
# Clear the flag as its normal that nothing
# will wait for this future to be resolved
# if there are no concurrent setup attempts
await future
raise
async def _async_process_dependencies(
hass: core.HomeAssistant, config: ConfigType, integration: loader.Integration
) -> list[str]:
"""Ensure all dependencies are set up.
Returns a list of dependencies which failed to set up.
"""
setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP, {}
)
dependencies_tasks = {
dep: setup_futures.get(dep)
or create_eager_task(
async_setup_component(hass, dep, config),
name=f"setup {dep} as dependency of {integration.domain}",
)
for dep in integration.dependencies
if dep not in hass.config.components
}
after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
to_be_loaded: dict[str, asyncio.Future[bool]] = hass.data.get(DATA_SETUP_DONE, {})
for dep in integration.after_dependencies:
if (
dep not in dependencies_tasks
and dep in to_be_loaded
and dep not in hass.config.components
):
after_dependencies_tasks[dep] = to_be_loaded[dep]
if not dependencies_tasks and not after_dependencies_tasks:
return []
if dependencies_tasks:
_LOGGER.debug(
"Dependency %s will wait for dependencies %s",
integration.domain,
dependencies_tasks.keys(),
)
if after_dependencies_tasks:
_LOGGER.debug(
"Dependency %s will wait for after dependencies %s",
integration.domain,
after_dependencies_tasks.keys(),
)
async with hass.timeout.async_freeze(integration.domain):
results = await asyncio.gather(
*dependencies_tasks.values(), *after_dependencies_tasks.values()
)
failed = [
domain for idx, domain in enumerate(dependencies_tasks) if not results[idx]
]
if failed:
_LOGGER.error(
"Unable to set up dependencies of '%s'. Setup failed for dependencies: %s",
integration.domain,
failed,
)
return failed
async def _async_setup_component( # noqa: C901
hass: core.HomeAssistant, domain: str, config: ConfigType
) -> bool:
"""Set up a component for Home Assistant.
This method is a coroutine.
"""
integration: loader.Integration | None = None
def log_error(msg: str, exc_info: Exception | None = None) -> None:
"""Log helper."""
if integration is None:
custom = ""
link = None
else:
custom = "" if integration.is_built_in else "custom integration "
link = integration.documentation
_LOGGER.error(
"Setup failed for %s'%s': %s", custom, domain, msg, exc_info=exc_info
)
async_notify_setup_error(hass, domain, link)
try:
integration = await loader.async_get_integration(hass, domain)
except loader.IntegrationNotFound:
log_error("Integration not found.")
return False
if integration.disabled:
log_error(f"Dependency is disabled - {integration.disabled}")
return False
# Validate all dependencies exist and there are no circular dependencies
if not await integration.resolve_dependencies():
return False
# Process requirements as soon as possible, so we can import the component
# without requiring imports to be in functions.
try:
await async_process_deps_reqs(hass, config, integration)
except HomeAssistantError as err:
log_error(str(err))
return False
# Some integrations fail on import because they call functions incorrectly.
# So we do it before validating config to catch these errors.
try:
component = await integration.async_get_component()
except ImportError as err:
log_error(f"Unable to import component: {err}", err)
return False
integration_config_info = await conf_util.async_process_component_config(
hass, config, integration, component
)
conf_util.async_handle_component_errors(hass, integration_config_info, integration)
processed_config = conf_util.async_drop_config_annotations(
integration_config_info, integration
)
for platform_exception in integration_config_info.exception_info_list:
if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS:
continue
async_notify_setup_error(
hass, platform_exception.platform_path, platform_exception.integration_link
)
if processed_config is None:
log_error("Invalid config.")
return False
# Detect attempt to setup integration which can be setup only from config entry
if (
domain in processed_config
and not hasattr(component, "async_setup")
and not hasattr(component, "setup")
and not hasattr(component, "CONFIG_SCHEMA")
):
_LOGGER.error(
(
"The '%s' integration does not support YAML setup, please remove it "
"from your configuration"
),
domain,
)
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"config_entry_only_{domain}",
is_fixable=False,
severity=IssueSeverity.ERROR,
issue_domain=domain,
translation_key="config_entry_only",
translation_placeholders={
"domain": domain,
"add_integration": f"/config/integrations/dashboard/add?domain={domain}",
},
)
start = timer()
_LOGGER.info("Setting up %s", domain)
integration_set = {domain}
load_translations_task: asyncio.Task[None] | None = None
if not translation.async_translations_loaded(hass, integration_set):
# For most cases we expect the translations are already
# loaded since we try to load them in bootstrap ahead of time.
# If for some reason the background task in bootstrap was too slow
# or the integration was added after bootstrap, we will load them here.
load_translations_task = create_eager_task(
translation.async_load_integrations(hass, integration_set)
)
with async_start_setup(hass, integration=domain, phase=SetupPhases.SETUP):
if hasattr(component, "PLATFORM_SCHEMA"):
# Entity components have their own warning
warn_task = None
else:
warn_task = hass.loop.call_later(
SLOW_SETUP_WARNING,
_LOGGER.warning,
"Setup of %s is taking over %s seconds.",
domain,
SLOW_SETUP_WARNING,
)
task: Awaitable[bool] | None = None
result: Any | bool = True
try:
if hasattr(component, "async_setup"):
task = component.async_setup(hass, processed_config)
elif hasattr(component, "setup"):
# This should not be replaced with hass.async_add_executor_job because
# we don't want to track this task in case it blocks startup.
task = hass.loop.run_in_executor(
None, component.setup, hass, processed_config
)
elif not hasattr(component, "async_setup_entry"):
log_error("No setup or config entry setup function defined.")
return False
if task:
async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, domain):
result = await task
except TimeoutError:
_LOGGER.error(
(
"Setup of '%s' is taking longer than %s seconds."
" Startup will proceed without waiting any longer"
),
domain,
SLOW_SETUP_MAX_WAIT,
)
return False
# pylint: disable-next=broad-except
except (asyncio.CancelledError, SystemExit, Exception):
_LOGGER.exception("Error during setup of component %s", domain)
async_notify_setup_error(hass, domain, integration.documentation)
return False
finally:
end = timer()
if warn_task:
warn_task.cancel()
_LOGGER.info("Setup of domain %s took %.1f seconds", domain, end - start)
if result is False:
log_error("Integration failed to initialize.")
return False
if result is not True:
log_error(
f"Integration {domain!r} did not return boolean if setup was "
"successful. Disabling component."
)
return False
if load_translations_task:
await load_translations_task
if integration.platforms_exists(("config_flow",)):
# If the integration has a config_flow, flush out async_setup calling create_task
# with an asyncio.sleep(0) so we can wait for import flows.
# Fragile but covered by test.
await asyncio.sleep(0)
await hass.config_entries.flow.async_wait_import_flow_initialized(domain)
# Add to components before the entry.async_setup
# call to avoid a deadlock when forwarding platforms
hass.config.components.add(domain)
if entries := hass.config_entries.async_entries(
domain, include_ignore=False, include_disabled=False
):
await asyncio.gather(
*(
create_eager_task(
entry.async_setup(hass, integration=integration),
name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}",
)
for entry in entries
)
)
# Cleanup
if domain in hass.data[DATA_SETUP]:
hass.data[DATA_SETUP].pop(domain)
hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain})
return True
async def async_prepare_setup_platform(
hass: core.HomeAssistant, hass_config: ConfigType, domain: str, platform_name: str
) -> ModuleType | None:
"""Load a platform and makes sure dependencies are setup.
This method is a coroutine.
"""
platform_path = PLATFORM_FORMAT.format(domain=domain, platform=platform_name)
def log_error(msg: str) -> None:
"""Log helper."""
_LOGGER.error(
"Unable to prepare setup for platform '%s': %s", platform_path, msg
)
async_notify_setup_error(hass, platform_path)
try:
integration = await loader.async_get_integration(hass, platform_name)
except loader.IntegrationNotFound:
log_error("Integration not found")
return None
# Process deps and reqs as soon as possible, so that requirements are
# available when we import the platform.
try:
await async_process_deps_reqs(hass, hass_config, integration)
except HomeAssistantError as err:
log_error(str(err))
return None
# Platforms cannot exist on their own, they are part of their integration.
# If the integration is not set up yet, and can be set up, set it up.
#
# We do this before we import the platform so the platform already knows
# where the top level component is.
#
if load_top_level_component := integration.domain not in hass.config.components:
try:
component = await integration.async_get_component()
except ImportError as exc:
log_error(f"Unable to import the component ({exc}).")
return None
try:
platform = await integration.async_get_platform(domain)
except ImportError as exc:
log_error(f"Platform not found ({exc}).")
return None
# Already loaded
if platform_path in hass.config.components:
return platform
# Platforms cannot exist on their own, they are part of their integration.
# If the integration is not set up yet, and can be set up, set it up.
if load_top_level_component:
if (
hasattr(component, "setup") or hasattr(component, "async_setup")
) and not await async_setup_component(hass, integration.domain, hass_config):
log_error("Unable to set up component.")
return None
return platform
async def async_process_deps_reqs(
hass: core.HomeAssistant, config: ConfigType, integration: loader.Integration
) -> None:
"""Process all dependencies and requirements for a module.
Module is a Python module of either a component or platform.
"""
if (processed := hass.data.get(DATA_DEPS_REQS)) is None:
processed = hass.data[DATA_DEPS_REQS] = set()
elif integration.domain in processed:
return
if failed_deps := await _async_process_dependencies(hass, config, integration):
raise DependencyError(failed_deps)
async with hass.timeout.async_freeze(integration.domain):
await requirements.async_get_integration_with_requirements(
hass, integration.domain
)
processed.add(integration.domain)
@core.callback
def async_when_setup(
hass: core.HomeAssistant,
component: str,
when_setup_cb: Callable[[core.HomeAssistant, str], Awaitable[None]],
) -> None:
"""Call a method when a component is setup."""
_async_when_setup(hass, component, when_setup_cb, False)
@core.callback
def async_when_setup_or_start(
hass: core.HomeAssistant,
component: str,
when_setup_cb: Callable[[core.HomeAssistant, str], Awaitable[None]],
) -> None:
"""Call a method when a component is setup or state is fired."""
_async_when_setup(hass, component, when_setup_cb, True)
@core.callback
def _async_when_setup(
hass: core.HomeAssistant,
component: str,
when_setup_cb: Callable[[core.HomeAssistant, str], Awaitable[None]],
start_event: bool,
) -> None:
"""Call a method when a component is setup or the start event fires."""
async def when_setup() -> None:
"""Call the callback."""
try:
await when_setup_cb(hass, component)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error handling when_setup callback for %s", component)
if component in hass.config.components:
hass.async_create_task(
when_setup(), f"when setup {component}", eager_start=True
)
return
listeners: list[CALLBACK_TYPE] = []
async def _matched_event(event: Event[Any]) -> None:
"""Call the callback when we matched an event."""
for listener in listeners:
listener()
await when_setup()
@callback
def _async_is_component_filter(event_data: EventComponentLoaded) -> bool:
"""Check if the event is for the component."""
return event_data[ATTR_COMPONENT] == component
listeners.append(
hass.bus.async_listen(
EVENT_COMPONENT_LOADED,
_matched_event,
event_filter=_async_is_component_filter,
run_immediately=True,
)
)
if start_event:
listeners.append(
hass.bus.async_listen(
EVENT_HOMEASSISTANT_START, _matched_event, run_immediately=True
)
)
@core.callback
def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]:
"""Return the complete list of loaded integrations."""
integrations = set()
for component in hass.config.components:
if "." not in component:
integrations.add(component)
continue
platform, _, domain = component.partition(".")
if domain in BASE_PLATFORMS:
integrations.add(platform)
return integrations
class SetupPhases(StrEnum):
"""Constants for setup time measurements."""
SETUP = "setup"
"""Set up of a component in __init__.py."""
CONFIG_ENTRY_SETUP = "config_entry_setup"
"""Set up of a config entry in __init__.py."""
PLATFORM_SETUP = "platform_setup"
"""Set up of a platform integration.
ex async_setup_platform or setup_platform or
a legacy platform like device_tracker.legacy
"""
CONFIG_ENTRY_PLATFORM_SETUP = "config_entry_platform_setup"
"""Set up of a platform in a config entry after the config entry is setup.
This is only for platforms that are not awaited in async_setup_entry.
"""
WAIT_BASE_PLATFORM_SETUP = "wait_base_component"
"""Wait time for the base component to be setup."""
WAIT_IMPORT_PLATFORMS = "wait_import_platforms"
"""Wait time for the platforms to import."""
WAIT_IMPORT_PACKAGES = "wait_import_packages"
"""Wait time for the packages to import."""
@contextlib.contextmanager
def async_pause_setup(
hass: core.HomeAssistant, phase: SetupPhases
) -> Generator[None, None, None]:
"""Keep track of time we are blocked waiting for other operations.
We want to count the time we wait for importing and
setting up the base components so we can subtract it
from the total setup time.
"""
if not (running := current_setup_group.get()):
# This means we are likely in a late platform setup
# that is running in a task so we do not want
# to subtract out the time later as nothing is waiting
# for the code inside the context manager to finish.
yield
return
started = time.monotonic()
try:
yield
finally:
time_taken = time.monotonic() - started
integration, group = running
# Add negative time for the time we waited
_setup_times(hass)[integration][group][phase] = -time_taken
def _setup_times(
hass: core.HomeAssistant,
) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]:
"""Return the setup timings default dict."""
if DATA_SETUP_TIME not in hass.data:
hass.data[DATA_SETUP_TIME] = defaultdict(
lambda: defaultdict(lambda: defaultdict(float))
)
return hass.data[DATA_SETUP_TIME] # type: ignore[no-any-return]
@contextlib.contextmanager
def async_start_setup(
hass: core.HomeAssistant,
integration: str,
phase: SetupPhases,
group: str | None = None,
) -> Generator[None, None, None]:
"""Keep track of when setup starts and finishes.
:param hass: Home Assistant instance
:param integration: The integration that is being setup
:param phase: The phase of setup
:param group: The group (config entry/platform instance) that is being setup
A group is a group of setups that run in parallel.
"""
if hass.is_stopping or hass.state is core.CoreState.running:
# Don't track setup times when we are shutting down or already running
# as we present the timings as "Integration startup time", and we
# don't want to add all the setup retry times to that.
yield
return
setup_started: dict[tuple[str, str | None], float]
setup_started = hass.data.setdefault(DATA_SETUP_STARTED, {})
current = (integration, group)
if current in setup_started:
# We are already inside another async_start_setup, this like means we
# are setting up a platform inside async_setup_entry so we should not
# record this as a new setup
yield
return
started = time.monotonic()
current_setup_group.set(current)
setup_started[current] = started
try:
yield
finally:
time_taken = time.monotonic() - started
del setup_started[current]
_setup_times(hass)[integration][group][phase] = time_taken
@callback
def async_get_setup_timings(hass: core.HomeAssistant) -> dict[str, float]:
"""Return timing data for each integration."""
setup_time = _setup_times(hass)
domain_timings: dict[str, float] = {}
top_level_timings: Mapping[SetupPhases, float]
for domain, timings in setup_time.items():
top_level_timings = timings.get(None, {})
total_top_level = sum(top_level_timings.values())
# Groups (config entries/platform instance) are setup in parallel so we
# take the max of the group timings and add it to the top level
group_totals = {
group: sum(group_timings.values())
for group, group_timings in timings.items()
if group is not None
}
group_max = max(group_totals.values(), default=0)
domain_timings[domain] = total_top_level + group_max
return domain_timings