home-assistant-core/homeassistant/setup.py
J. Nick Koston def6c5c21c
Refactor integration startup time tracking to reduce overhead (#110136)
* Refactor integration startup time tracking to reduce overhead

- Use monotonic time for watching integration startup time as it avoids incorrect values if time moves backwards because of ntp during startup and reduces many time conversions since we want durations in seconds and not local time

- Use loop scheduling instead of a task

- Moves all the dispatcher logic into the new _WatchPendingSetups

* websocket as well

* tweaks

* simplify logic

* preserve logic

* preserve logic

* lint

* adjust
2024-02-17 21:47:55 -05:00

587 lines
20 KiB
Python

"""All methods needed to bootstrap a Home Assistant instance."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Generator, Iterable
import contextlib
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, HomeAssistant, callback
from .exceptions import DependencyError, HomeAssistantError
from .helpers.issue_registry import IssueSeverity, async_create_issue
from .helpers.typing import ConfigType, EventType
from .util import ensure_unique_string
_LOGGER = logging.getLogger(__name__)
ATTR_COMPONENT: Final = "component"
BASE_PLATFORMS = {platform.value for platform in Platform}
# DATA_SETUP is a dict[str, asyncio.Task[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.Event], 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_DONE is a dict [str, datetime], indicating when an attempt
# to setup a component started.
DATA_SETUP_STARTED = "setup_started"
# DATA_SETUP_TIME is a dict [str, timedelta], indicating how time was spent
# setting up a component.
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
"""
hass.data.setdefault(DATA_SETUP_DONE, {})
hass.data[DATA_SETUP_DONE].update({domain: asyncio.Event() 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_tasks: dict[str, asyncio.Task[bool]] = hass.data.setdefault(DATA_SETUP, {})
if domain in setup_tasks:
return await setup_tasks[domain]
task = setup_tasks[domain] = hass.async_create_task(
_async_setup_component(hass, domain, config), f"setup component {domain}"
)
try:
return await task
finally:
if domain in hass.data.get(DATA_SETUP_DONE, {}):
hass.data[DATA_SETUP_DONE].pop(domain).set()
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.
"""
dependencies_tasks = {
dep: hass.loop.create_task(async_setup_component(hass, dep, config))
for dep in integration.dependencies
if dep not in hass.config.components
}
after_dependencies_tasks = {}
to_be_loaded = 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] = hass.loop.create_task(
to_be_loaded[dep].wait()
)
if not dependencies_tasks and not after_dependencies_tasks:
return []
if dependencies_tasks:
_LOGGER.debug(
"Dependency %s will wait for dependencies %s",
integration.domain,
list(dependencies_tasks),
)
if after_dependencies_tasks:
_LOGGER.debug(
"Dependency %s will wait for after dependencies %s",
integration.domain,
list(after_dependencies_tasks),
)
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,
", ".join(failed),
)
return failed
async def _async_setup_component(
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 = integration.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
)
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)
with async_start_setup(hass, [domain]):
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
# Flush out async_setup calling create_task. 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(
*(
asyncio.create_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
try:
platform = integration.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 integration.domain not in hass.config.components:
try:
component = integration.get_component()
except ImportError as exc:
log_error(f"Unable to import the component ({exc}).")
return None
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}")
return
listeners: list[CALLBACK_TYPE] = []
async def _matched_event(event: EventType[EventComponentLoaded]) -> None:
"""Call the callback when we matched an event."""
for listener in listeners:
listener()
await when_setup()
@callback
def _async_is_component_filter(event: EventType[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, # type: ignore[arg-type]
event_filter=_async_is_component_filter, # type: ignore[arg-type]
)
)
if start_event:
listeners.append(
hass.bus.async_listen(EVENT_HOMEASSISTANT_START, _matched_event) # type: ignore[arg-type]
)
@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
@contextlib.contextmanager
def async_start_setup(
hass: core.HomeAssistant, components: Iterable[str]
) -> Generator[None, None, None]:
"""Keep track of when setup starts and finishes."""
setup_started = hass.data.setdefault(DATA_SETUP_STARTED, {})
started = time.monotonic()
unique_components: dict[str, str] = {}
for domain in components:
unique = ensure_unique_string(domain, setup_started)
unique_components[unique] = domain
setup_started[unique] = started
yield
setup_time: dict[str, float] = hass.data.setdefault(DATA_SETUP_TIME, {})
time_taken = time.monotonic() - started
for unique, domain in unique_components.items():
del setup_started[unique]
integration = domain.partition(".")[0]
if integration in setup_time:
setup_time[integration] += time_taken
else:
setup_time[integration] = time_taken