Raise and suppress stack trace when reloading yaml fails (#102410)

* Allow async_integration_yaml_config to raise

* Docstr - split check

* Implement as wrapper, return dataclass

* Fix setup error handling

* Fix reload test mock

* Move log_messages to error handler

* Remove unreachable code

* Remove config test helper

* Refactor and ensure notifications during setup

* Remove redundat error, adjust tests notifications

* Fix patch

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Follow up comments

* Add call_back decorator

* Split long lines

* Update exception abbreviations

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jan Bouwhuis 2023-11-24 17:34:45 +01:00 committed by GitHub
parent 852fb58ca8
commit af71c2bb45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 954 additions and 195 deletions

View File

@ -194,7 +194,9 @@ async def async_setup_platform(
integration = await async_get_integration(hass, SCENE_DOMAIN)
conf = await conf_util.async_process_component_config(hass, config, integration)
conf = await conf_util.async_process_component_and_handle_errors(
hass, config, integration
)
if not (conf and platform):
return

View File

@ -138,6 +138,36 @@
}
},
"exceptions": {
"component_import_err": {
"message": "Unable to import {domain}: {error}"
},
"config_platform_import_err": {
"message": "Error importing config platform {domain}: {error}"
},
"config_validation_err": {
"message": "Invalid config for integration {domain} at {config_file}, line {line}: {error}. Check the logs for more information."
},
"config_validator_unknown_err": {
"message": "Unknown error calling {domain} config validator. Check the logs for more information."
},
"config_schema_unknown_err": {
"message": "Unknown error calling {domain} CONFIG_SCHEMA. Check the logs for more information."
},
"integration_config_error": {
"message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information."
},
"platform_component_load_err": {
"message": "Platform error: {domain} - {error}. Check the logs for more information."
},
"platform_component_load_exc": {
"message": "Platform error: {domain} - {error}. Check the logs for more information."
},
"platform_config_validation_err": {
"message": "Invalid config for {domain} from integration {p_name} at file {config_file}, line {line}: {error}. Check the logs for more information."
},
"platform_schema_validator_err": {
"message": "Unknown error when validating config for {domain} from integration {p_name}"
},
"service_not_found": {
"message": "Service {domain}.{service} not found."
}

View File

@ -4,7 +4,10 @@ import logging
import voluptuous as vol
from homeassistant.components import frontend, websocket_api
from homeassistant.config import async_hass_config_yaml, async_process_component_config
from homeassistant.config import (
async_hass_config_yaml,
async_process_component_and_handle_errors,
)
from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
@ -85,7 +88,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
integration = await async_get_integration(hass, DOMAIN)
config = await async_process_component_config(hass, conf, integration)
config = await async_process_component_and_handle_errors(
hass, conf, integration
)
if config is None:
raise HomeAssistantError("Config validation failed")

View File

@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
HomeAssistantError,
ConfigValidationError,
ServiceValidationError,
TemplateError,
Unauthorized,
@ -417,14 +417,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _reload_config(call: ServiceCall) -> None:
"""Reload the platforms."""
# Fetch updated manually configured items and validate
if (
config_yaml := await async_integration_yaml_config(hass, DOMAIN)
) is None:
# Raise in case we have an invalid configuration
raise HomeAssistantError(
"Error reloading manually configured MQTT items, "
"check your configuration.yaml"
try:
config_yaml = await async_integration_yaml_config(
hass, DOMAIN, raise_on_failure=True
)
except ConfigValidationError as ex:
raise ServiceValidationError(
str(ex),
translation_domain=ex.translation_domain,
translation_key=ex.translation_key,
translation_placeholders=ex.translation_placeholders,
) from ex
# Check the schema before continuing reload
await async_check_config_schema(hass, config_yaml)

View File

@ -34,8 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.error(err)
return
conf = await conf_util.async_process_component_config(
hass, unprocessed_conf, await async_get_integration(hass, DOMAIN)
integration = await async_get_integration(hass, DOMAIN)
conf = await conf_util.async_process_component_and_handle_errors(
hass, unprocessed_conf, integration
)
if conf is None:

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from collections import OrderedDict
from collections.abc import Callable, Sequence
from contextlib import suppress
from dataclasses import dataclass
from enum import StrEnum
from functools import reduce
import logging
import operator
@ -12,7 +14,7 @@ from pathlib import Path
import re
import shutil
from types import ModuleType
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from awesomeversion import AwesomeVersion
@ -54,7 +56,7 @@ from .const import (
__version__,
)
from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback
from .exceptions import HomeAssistantError
from .exceptions import ConfigValidationError, HomeAssistantError
from .generated.currencies import HISTORIC_CURRENCIES
from .helpers import (
config_per_platform,
@ -66,13 +68,13 @@ from .helpers.entity_values import EntityValues
from .helpers.typing import ConfigType
from .loader import ComponentProtocol, Integration, IntegrationNotFound
from .requirements import RequirementsNotFound, async_get_integration_with_requirements
from .setup import async_notify_setup_error
from .util.package import is_docker_env
from .util.unit_system import get_unit_system, validate_unit_system
from .util.yaml import SECRET_YAML, Secrets, load_yaml
_LOGGER = logging.getLogger(__name__)
DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors"
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
RE_ASCII = re.compile(r"\033\[[^m]*m")
YAML_CONFIG_FILE = "configuration.yaml"
@ -117,6 +119,46 @@ tts:
"""
class ConfigErrorTranslationKey(StrEnum):
"""Config error translation keys for config errors."""
# translation keys with a generated config related message text
CONFIG_VALIDATION_ERR = "config_validation_err"
PLATFORM_CONFIG_VALIDATION_ERR = "platform_config_validation_err"
# translation keys with a general static message text
COMPONENT_IMPORT_ERR = "component_import_err"
CONFIG_PLATFORM_IMPORT_ERR = "config_platform_import_err"
CONFIG_VALIDATOR_UNKNOWN_ERR = "config_validator_unknown_err"
CONFIG_SCHEMA_UNKNOWN_ERR = "config_schema_unknown_err"
PLATFORM_VALIDATOR_UNKNOWN_ERR = "platform_validator_unknown_err"
PLATFORM_COMPONENT_LOAD_ERR = "platform_component_load_err"
PLATFORM_COMPONENT_LOAD_EXC = "platform_component_load_exc"
PLATFORM_SCHEMA_VALIDATOR_ERR = "platform_schema_validator_err"
# translation key in case multiple errors occurred
INTEGRATION_CONFIG_ERROR = "integration_config_error"
@dataclass
class ConfigExceptionInfo:
"""Configuration exception info class."""
exception: Exception
translation_key: ConfigErrorTranslationKey
platform_name: str
config: ConfigType
integration_link: str | None
@dataclass
class IntegrationConfigInfo:
"""Configuration for an integration and exception information."""
config: ConfigType | None
exception_info_list: list[ConfigExceptionInfo]
def _no_duplicate_auth_provider(
configs: Sequence[dict[str, Any]]
) -> Sequence[dict[str, Any]]:
@ -1025,21 +1067,193 @@ async def merge_packages_config(
return config
async def async_process_component_config( # noqa: C901
hass: HomeAssistant, config: ConfigType, integration: Integration
) -> ConfigType | None:
"""Check component configuration and return processed configuration.
@callback
def _get_log_message_and_stack_print_pref(
hass: HomeAssistant, domain: str, platform_exception: ConfigExceptionInfo
) -> tuple[str | None, bool, dict[str, str]]:
"""Get message to log and print stack trace preference."""
exception = platform_exception.exception
platform_name = platform_exception.platform_name
platform_config = platform_exception.config
link = platform_exception.integration_link
Returns None on error.
placeholders: dict[str, str] = {"domain": domain, "error": str(exception)}
log_message_mapping: dict[ConfigErrorTranslationKey, tuple[str, bool]] = {
ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR: (
f"Unable to import {domain}: {exception}",
False,
),
ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR: (
f"Error importing config platform {domain}: {exception}",
False,
),
ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR: (
f"Unknown error calling {domain} config validator",
True,
),
ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR: (
f"Unknown error calling {domain} CONFIG_SCHEMA",
True,
),
ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: (
f"Unknown error validating {platform_name} platform config with {domain} "
"component platform schema",
True,
),
ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR: (
f"Platform error: {domain} - {exception}",
False,
),
ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC: (
f"Platform error: {domain} - {exception}",
True,
),
ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: (
f"Unknown error validating config for {platform_name} platform "
f"for {domain} component with PLATFORM_SCHEMA",
True,
),
}
log_message_show_stack_trace = log_message_mapping.get(
platform_exception.translation_key
)
if log_message_show_stack_trace is None:
# If no pre defined log_message is set, we generate an enriched error
# message, so we can notify about it during setup
show_stack_trace = False
if isinstance(exception, vol.Invalid):
log_message = format_schema_error(
hass, exception, platform_name, platform_config, link
)
if annotation := find_annotation(platform_config, exception.path):
placeholders["config_file"], line = annotation
placeholders["line"] = str(line)
else:
if TYPE_CHECKING:
assert isinstance(exception, HomeAssistantError)
log_message = format_homeassistant_error(
hass, exception, platform_name, platform_config, link
)
if annotation := find_annotation(platform_config, [platform_name]):
placeholders["config_file"], line = annotation
placeholders["line"] = str(line)
show_stack_trace = True
return (log_message, show_stack_trace, placeholders)
assert isinstance(log_message_show_stack_trace, tuple)
return (*log_message_show_stack_trace, placeholders)
async def async_process_component_and_handle_errors(
hass: HomeAssistant,
config: ConfigType,
integration: Integration,
raise_on_failure: bool = False,
) -> ConfigType | None:
"""Process and component configuration and handle errors.
In case of errors:
- Print the error messages to the log.
- Raise a ConfigValidationError if raise_on_failure is set.
Returns the integration config or `None`.
"""
integration_config_info = await async_process_component_config(
hass, config, integration
)
return async_handle_component_errors(
hass, integration_config_info, integration, raise_on_failure
)
@callback
def async_handle_component_errors(
hass: HomeAssistant,
integration_config_info: IntegrationConfigInfo,
integration: Integration,
raise_on_failure: bool = False,
) -> ConfigType | None:
"""Handle component configuration errors from async_process_component_config.
In case of errors:
- Print the error messages to the log.
- Raise a ConfigValidationError if raise_on_failure is set.
Returns the integration config or `None`.
"""
if not (config_exception_info := integration_config_info.exception_info_list):
return integration_config_info.config
platform_exception: ConfigExceptionInfo
domain = integration.domain
placeholders: dict[str, str]
for platform_exception in config_exception_info:
exception = platform_exception.exception
(
log_message,
show_stack_trace,
placeholders,
) = _get_log_message_and_stack_print_pref(hass, domain, platform_exception)
_LOGGER.error(
log_message,
exc_info=exception if show_stack_trace else None,
)
if not raise_on_failure:
return integration_config_info.config
if len(config_exception_info) == 1:
translation_key = platform_exception.translation_key
else:
translation_key = ConfigErrorTranslationKey.INTEGRATION_CONFIG_ERROR
errors = str(len(config_exception_info))
log_message = (
f"Failed to process component config for integration {domain} "
f"due to multiple errors ({errors}), check the logs for more information."
)
placeholders = {
"domain": domain,
"errors": errors,
}
raise ConfigValidationError(
str(log_message),
[platform_exception.exception for platform_exception in config_exception_info],
translation_domain="homeassistant",
translation_key=translation_key,
translation_placeholders=placeholders,
)
async def async_process_component_config( # noqa: C901
hass: HomeAssistant,
config: ConfigType,
integration: Integration,
) -> IntegrationConfigInfo:
"""Check component configuration.
Returns processed configuration and exception information.
This method must be run in the event loop.
"""
domain = integration.domain
integration_docs = integration.documentation
config_exceptions: list[ConfigExceptionInfo] = []
try:
component = integration.get_component()
except LOAD_EXCEPTIONS as ex:
_LOGGER.error("Unable to import %s: %s", domain, ex)
return None
except LOAD_EXCEPTIONS as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR,
domain,
config,
integration_docs,
)
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
# Check if the integration has a custom config validator
config_validator = None
@ -1050,62 +1264,101 @@ async def async_process_component_config( # noqa: C901
# If the config platform contains bad imports, make sure
# that still fails.
if err.name != f"{integration.pkg_path}.config":
_LOGGER.error("Error importing config platform %s: %s", domain, err)
return None
exc_info = ConfigExceptionInfo(
err,
ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR,
domain,
config,
integration_docs,
)
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
if config_validator is not None and hasattr(
config_validator, "async_validate_config"
):
try:
return ( # type: ignore[no-any-return]
await config_validator.async_validate_config(hass, config)
return IntegrationConfigInfo(
await config_validator.async_validate_config(hass, config), []
)
except (vol.Invalid, HomeAssistantError) as ex:
async_log_config_validator_error(
ex, domain, config, hass, integration.documentation
except (vol.Invalid, HomeAssistantError) as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR,
domain,
config,
integration_docs,
)
return None
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unknown error calling %s config validator", domain)
return None
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
except Exception as exc: # pylint: disable=broad-except
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR,
domain,
config,
integration_docs,
)
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
# No custom config validator, proceed with schema validation
if hasattr(component, "CONFIG_SCHEMA"):
try:
return component.CONFIG_SCHEMA(config) # type: ignore[no-any-return]
except vol.Invalid as ex:
async_log_schema_error(ex, domain, config, hass, integration.documentation)
return None
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unknown error calling %s CONFIG_SCHEMA", domain)
return None
return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), [])
except vol.Invalid as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR,
domain,
config,
integration_docs,
)
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
except Exception as exc: # pylint: disable=broad-except
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR,
domain,
config,
integration_docs,
)
config_exceptions.append(exc_info)
return IntegrationConfigInfo(None, config_exceptions)
component_platform_schema = getattr(
component, "PLATFORM_SCHEMA_BASE", getattr(component, "PLATFORM_SCHEMA", None)
)
if component_platform_schema is None:
return config
return IntegrationConfigInfo(config, [])
platforms = []
platforms: list[ConfigType] = []
for p_name, p_config in config_per_platform(config, domain):
# Validate component specific platform schema
platform_name = f"{domain}.{p_name}"
try:
p_validated = component_platform_schema(p_config)
except vol.Invalid as ex:
async_log_schema_error(
ex, domain, p_config, hass, integration.documentation
)
continue
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
(
"Unknown error validating %s platform config with %s component"
" platform schema"
),
p_name,
except vol.Invalid as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR,
domain,
p_config,
integration_docs,
)
config_exceptions.append(exc_info)
continue
except Exception as exc: # pylint: disable=broad-except
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR,
str(p_name),
config,
integration_docs,
)
config_exceptions.append(exc_info)
continue
# Not all platform components follow same pattern for platforms
@ -1117,38 +1370,53 @@ async def async_process_component_config( # noqa: C901
try:
p_integration = await async_get_integration_with_requirements(hass, p_name)
except (RequirementsNotFound, IntegrationNotFound) as ex:
_LOGGER.error("Platform error: %s - %s", domain, ex)
except (RequirementsNotFound, IntegrationNotFound) as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR,
platform_name,
p_config,
integration_docs,
)
config_exceptions.append(exc_info)
continue
try:
platform = p_integration.get_platform(domain)
except LOAD_EXCEPTIONS:
_LOGGER.exception("Platform error: %s", domain)
except LOAD_EXCEPTIONS as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC,
platform_name,
p_config,
integration_docs,
)
config_exceptions.append(exc_info)
continue
# Validate platform specific schema
if hasattr(platform, "PLATFORM_SCHEMA"):
try:
p_validated = platform.PLATFORM_SCHEMA(p_config)
except vol.Invalid as ex:
async_log_schema_error(
ex,
f"{domain}.{p_name}",
except vol.Invalid as exc:
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR,
platform_name,
p_config,
hass,
p_integration.documentation,
)
config_exceptions.append(exc_info)
continue
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
(
"Unknown error validating config for %s platform for %s"
" component with PLATFORM_SCHEMA"
),
except Exception as exc: # pylint: disable=broad-except
exc_info = ConfigExceptionInfo(
exc,
ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR,
p_name,
domain,
p_config,
p_integration.documentation,
)
config_exceptions.append(exc_info)
continue
platforms.append(p_validated)
@ -1158,7 +1426,7 @@ async def async_process_component_config( # noqa: C901
config = config_without_domain(config, domain)
config[domain] = platforms
return config
return IntegrationConfigInfo(config, config_exceptions)
@callback
@ -1183,36 +1451,6 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None:
return res.error_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"
)
def safe_mode_enabled(config_dir: str) -> bool:
"""Return if safe mode is enabled.

View File

@ -26,6 +26,31 @@ class HomeAssistantError(Exception):
self.translation_placeholders = translation_placeholders
class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]):
"""A validation exception occurred when validating the configuration."""
def __init__(
self,
message: str,
exceptions: list[Exception],
translation_domain: str | None = None,
translation_key: str | None = None,
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Initialize exception."""
super().__init__(
*(message, exceptions),
translation_domain=translation_domain,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
self._message = message
def __str__(self) -> str:
"""Return exception message string."""
return self._message
class ServiceValidationError(HomeAssistantError):
"""A validation exception occurred when calling a service."""

View File

@ -355,7 +355,7 @@ class EntityComponent(Generic[_EntityT]):
integration = await async_get_integration(self.hass, self.domain)
processed_conf = await conf_util.async_process_component_config(
processed_conf = await conf_util.async_process_component_and_handle_errors(
self.hass, conf, integration
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Iterable
import logging
from typing import Any
from typing import Any, Literal, overload
from homeassistant import config as conf_util
from homeassistant.const import SERVICE_RELOAD
@ -60,7 +60,7 @@ async def _resetup_platform(
"""Resetup a platform."""
integration = await async_get_integration(hass, platform_domain)
conf = await conf_util.async_process_component_config(
conf = await conf_util.async_process_component_and_handle_errors(
hass, unprocessed_config, integration
)
@ -136,14 +136,41 @@ async def _async_reconfig_platform(
await asyncio.gather(*tasks)
@overload
async def async_integration_yaml_config(
hass: HomeAssistant, integration_name: str
) -> ConfigType | None:
...
@overload
async def async_integration_yaml_config(
hass: HomeAssistant,
integration_name: str,
*,
raise_on_failure: Literal[True],
) -> ConfigType:
...
@overload
async def async_integration_yaml_config(
hass: HomeAssistant,
integration_name: str,
*,
raise_on_failure: Literal[False] | bool,
) -> ConfigType | None:
...
async def async_integration_yaml_config(
hass: HomeAssistant, integration_name: str, *, raise_on_failure: bool = False
) -> ConfigType | None:
"""Fetch the latest yaml configuration for an integration."""
integration = await async_get_integration(hass, integration_name)
return await conf_util.async_process_component_config(
hass, await conf_util.async_hass_config_yaml(hass), integration
config = await conf_util.async_hass_config_yaml(hass)
return await conf_util.async_process_component_and_handle_errors(
hass, config, integration, raise_on_failure=raise_on_failure
)

View File

@ -11,14 +11,13 @@ from types import ModuleType
from typing import Any
from . import config as conf_util, core, loader, requirements
from .config import async_notify_setup_error
from .const import (
EVENT_COMPONENT_LOADED,
EVENT_HOMEASSISTANT_START,
PLATFORM_FORMAT,
Platform,
)
from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN
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
@ -56,10 +55,47 @@ 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
@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.
@ -217,10 +253,18 @@ async def _async_setup_component(
log_error(f"Unable to import component: {err}", err)
return False
processed_config = await conf_util.async_process_component_config(
integration_config_info = await conf_util.async_process_component_config(
hass, config, integration
)
processed_config = conf_util.async_handle_component_errors(
hass, 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_name, platform_exception.integration_link
)
if processed_config is None:
log_error("Invalid config.")
return False

View File

@ -984,7 +984,10 @@ def assert_setup_component(count, domain=None):
async def mock_psc(hass, config_input, integration):
"""Mock the prepare_setup_component to capture config."""
domain_input = integration.domain
res = await async_process_component_config(hass, config_input, integration)
integration_config_info = await async_process_component_config(
hass, config_input, integration
)
res = integration_config_info.config
config[domain_input] = None if res is None else res.get(domain_input)
_LOGGER.debug(
"Configuration for %s, Validated: %s, Original %s",
@ -992,7 +995,7 @@ def assert_setup_component(count, domain=None):
config[domain_input],
config_input.get(domain_input),
)
return res
return integration_config_info
assert isinstance(config, dict)
with patch("homeassistant.config.async_process_component_config", mock_psc):

View File

@ -3,10 +3,12 @@ import logging
from unittest.mock import AsyncMock, Mock, patch
import pytest
import voluptuous as vol
from homeassistant import config
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigValidationError, HomeAssistantError
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.reload import (
@ -139,7 +141,9 @@ async def test_setup_reload_service_when_async_process_component_config_fails(
yaml_path = get_fixture_path("helpers/reload_configuration.yaml")
with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch.object(
config, "async_process_component_config", return_value=None
config,
"async_process_component_config",
return_value=config.IntegrationConfigInfo(None, []),
):
await hass.services.async_call(
PLATFORM,
@ -208,8 +212,49 @@ async def test_async_integration_yaml_config(hass: HomeAssistant) -> None:
yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml")
with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
processed_config = await async_integration_yaml_config(hass, DOMAIN)
assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]}
# Test fetching yaml config does not raise when the raise_on_failure option is set
processed_config = await async_integration_yaml_config(
hass, DOMAIN, raise_on_failure=True
)
assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]}
assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]}
async def test_async_integration_failing_yaml_config(hass: HomeAssistant) -> None:
"""Test reloading yaml config for an integration fails.
In case an integration reloads its yaml configuration it should throw when
the new config failed to load and raise_on_failure is set to True.
"""
schema_without_name_attr = vol.Schema({vol.Required("some_option"): str})
mock_integration(hass, MockModule(DOMAIN, config_schema=schema_without_name_attr))
yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml")
with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
# Test fetching yaml config does not raise without raise_on_failure option
processed_config = await async_integration_yaml_config(hass, DOMAIN)
assert processed_config is None
# Test fetching yaml config does not raise when the raise_on_failure option is set
with pytest.raises(ConfigValidationError):
await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True)
async def test_async_integration_failing_on_reload(hass: HomeAssistant) -> None:
"""Test reloading yaml config for an integration fails with an other exception.
In case an integration reloads its yaml configuration it should throw when
the new config failed to load and raise_on_failure is set to True.
"""
mock_integration(hass, MockModule(DOMAIN))
yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml")
with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch(
"homeassistant.config.async_process_component_config",
side_effect=HomeAssistantError(),
), pytest.raises(HomeAssistantError):
# Test fetching yaml config does raise when the raise_on_failure option is set
await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True)
async def test_async_integration_missing_yaml_config(hass: HomeAssistant) -> None:

View File

@ -1013,7 +1013,10 @@ async def test_bootstrap_dependencies(
with patch(
"homeassistant.setup.loader.async_get_integrations",
side_effect=mock_async_get_integrations,
), patch("homeassistant.config.async_process_component_config", return_value={}):
), patch(
"homeassistant.config.async_process_component_config",
return_value=config_util.IntegrationConfigInfo({}, []),
):
bootstrap.async_set_domains_to_be_loaded(hass, {integration})
await bootstrap.async_setup_multi_components(hass, {integration}, {})
await hass.async_block_till_done()

View File

@ -30,6 +30,7 @@ from homeassistant.const import (
__version__,
)
from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError
from homeassistant.exceptions import ConfigValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
import homeassistant.helpers.check_config as check_config
from homeassistant.helpers.entity import Entity
@ -1427,71 +1428,132 @@ async def test_component_config_exceptions(
) -> None:
"""Test unexpected exceptions validating component config."""
# Config validator
test_integration = Mock(
domain="test_domain",
get_platform=Mock(
return_value=Mock(
async_validate_config=AsyncMock(side_effect=ValueError("broken"))
)
),
)
assert (
await config_util.async_process_component_config(
hass,
{},
integration=Mock(
domain="test_domain",
get_platform=Mock(
return_value=Mock(
async_validate_config=AsyncMock(
side_effect=ValueError("broken")
)
)
),
),
await config_util.async_process_component_and_handle_errors(
hass, {}, integration=test_integration
)
is None
)
assert "ValueError: broken" in caplog.text
assert "Unknown error calling test_domain config validator" in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass, {}, integration=test_integration, raise_on_failure=True
)
assert "ValueError: broken" in caplog.text
assert "Unknown error calling test_domain config validator" in caplog.text
assert str(ex.value) == "Unknown error calling test_domain config validator"
# component.CONFIG_SCHEMA
test_integration = Mock(
domain="test_domain",
get_platform=Mock(
return_value=Mock(
async_validate_config=AsyncMock(
side_effect=HomeAssistantError("broken")
)
)
),
get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
)
caplog.clear()
assert (
await config_util.async_process_component_config(
hass,
{},
integration=Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(
return_value=Mock(
CONFIG_SCHEMA=Mock(side_effect=ValueError("broken"))
)
),
),
await config_util.async_process_component_and_handle_errors(
hass, {}, integration=test_integration, raise_on_failure=False
)
is None
)
assert "Invalid config for 'test_domain': broken" in caplog.text
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass, {}, integration=test_integration, raise_on_failure=True
)
assert "Invalid config for 'test_domain': broken" in str(ex.value)
# component.CONFIG_SCHEMA
caplog.clear()
test_integration = Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(
return_value=Mock(CONFIG_SCHEMA=Mock(side_effect=ValueError("broken")))
),
)
assert (
await config_util.async_process_component_and_handle_errors(
hass,
{},
integration=test_integration,
raise_on_failure=False,
)
is None
)
assert "ValueError: broken" in caplog.text
assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass,
{},
integration=test_integration,
raise_on_failure=True,
)
assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text
assert str(ex.value) == "Unknown error calling test_domain CONFIG_SCHEMA"
# component.PLATFORM_SCHEMA
caplog.clear()
assert await config_util.async_process_component_config(
test_integration = Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(
return_value=Mock(
spec=["PLATFORM_SCHEMA_BASE"],
PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")),
)
),
)
assert await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(
return_value=Mock(
spec=["PLATFORM_SCHEMA_BASE"],
PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")),
)
),
),
integration=test_integration,
raise_on_failure=False,
) == {"test_domain": []}
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating test_platform platform config "
"with test_domain component platform schema"
"Unknown error validating config for test_platform platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=test_integration,
raise_on_failure=True,
)
assert (
"Unknown error validating config for test_platform platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
assert str(ex.value) == (
"Unknown error validating config for test_platform platform "
"for test_domain component with PLATFORM_SCHEMA"
)
# platform.PLATFORM_SCHEMA
caplog.clear()
test_integration = Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
)
with patch(
"homeassistant.config.async_get_integration_with_requirements",
return_value=Mock( # integration that owns platform
@ -1502,67 +1564,337 @@ async def test_component_config_exceptions(
)
),
):
assert await config_util.async_process_component_config(
assert await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
),
integration=test_integration,
raise_on_failure=False,
) == {"test_domain": []}
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating config for test_platform platform for test_domain"
" component with PLATFORM_SCHEMA"
) in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError) as ex:
assert await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=test_integration,
raise_on_failure=True,
)
assert (
"Unknown error validating config for test_platform platform for test_domain"
" component with PLATFORM_SCHEMA"
) in str(ex.value)
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating config for test_platform platform for test_domain"
" component with PLATFORM_SCHEMA" in caplog.text
)
# Test multiple platform failures
assert await config_util.async_process_component_and_handle_errors(
hass,
{
"test_domain": [
{"platform": "test_platform1"},
{"platform": "test_platform2"},
]
},
integration=test_integration,
raise_on_failure=False,
) == {"test_domain": []}
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating config for test_platform1 platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
assert (
"Unknown error validating config for test_platform2 platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError) as ex:
assert await config_util.async_process_component_and_handle_errors(
hass,
{
"test_domain": [
{"platform": "test_platform1"},
{"platform": "test_platform2"},
]
},
integration=test_integration,
raise_on_failure=True,
)
assert (
"Failed to process component config for integration test_domain"
" due to multiple errors (2), check the logs for more information."
) in str(ex.value)
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating config for test_platform1 platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
assert (
"Unknown error validating config for test_platform2 platform "
"for test_domain component with PLATFORM_SCHEMA"
) in caplog.text
# get_platform("domain") raising on ImportError
caplog.clear()
test_integration = Mock(
domain="test_domain",
get_platform=Mock(return_value=None),
get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
)
import_error = ImportError(
("ModuleNotFoundError: No module named 'not_installed_something'"),
name="not_installed_something",
)
with patch(
"homeassistant.config.async_get_integration_with_requirements",
return_value=Mock( # integration that owns platform
get_platform=Mock(side_effect=import_error)
),
):
assert await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=test_integration,
raise_on_failure=False,
) == {"test_domain": []}
assert (
"ImportError: ModuleNotFoundError: No module named "
"'not_installed_something'" in caplog.text
)
caplog.clear()
with pytest.raises(HomeAssistantError) as ex:
assert await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {"platform": "test_platform"}},
integration=test_integration,
raise_on_failure=True,
)
assert (
"ImportError: ModuleNotFoundError: No module named "
"'not_installed_something'" in caplog.text
)
assert (
"Platform error: test_domain - ModuleNotFoundError: "
"No module named 'not_installed_something'"
) in caplog.text
assert (
"Platform error: test_domain - ModuleNotFoundError: "
"No module named 'not_installed_something'"
) in str(ex.value)
# get_platform("config") raising
caplog.clear()
test_integration = Mock(
pkg_path="homeassistant.components.test_domain",
domain="test_domain",
get_platform=Mock(
side_effect=ImportError(
("ModuleNotFoundError: No module named 'not_installed_something'"),
name="not_installed_something",
)
),
)
assert (
await config_util.async_process_component_config(
await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {}},
integration=Mock(
pkg_path="homeassistant.components.test_domain",
domain="test_domain",
get_platform=Mock(
side_effect=ImportError(
(
"ModuleNotFoundError: No module named"
" 'not_installed_something'"
),
name="not_installed_something",
)
),
),
integration=test_integration,
raise_on_failure=False,
)
is None
)
assert (
"Error importing config platform test_domain: ModuleNotFoundError: No module"
" named 'not_installed_something'" in caplog.text
"Error importing config platform test_domain: ModuleNotFoundError: "
"No module named 'not_installed_something'" in caplog.text
)
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {}},
integration=test_integration,
raise_on_failure=True,
)
assert (
"Error importing config platform test_domain: ModuleNotFoundError: "
"No module named 'not_installed_something'" in caplog.text
)
assert (
"Error importing config platform test_domain: ModuleNotFoundError: "
"No module named 'not_installed_something'" in str(ex.value)
)
# get_component raising
caplog.clear()
test_integration = Mock(
pkg_path="homeassistant.components.test_domain",
domain="test_domain",
get_component=Mock(
side_effect=FileNotFoundError("No such file or directory: b'liblibc.a'")
),
)
assert (
await config_util.async_process_component_config(
await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {}},
integration=Mock(
pkg_path="homeassistant.components.test_domain",
domain="test_domain",
get_component=Mock(
side_effect=FileNotFoundError(
"No such file or directory: b'liblibc.a'"
)
),
),
integration=test_integration,
raise_on_failure=False,
)
is None
)
assert "Unable to import test_domain: No such file or directory" in caplog.text
with pytest.raises(HomeAssistantError) as ex:
await config_util.async_process_component_and_handle_errors(
hass,
{"test_domain": {}},
integration=test_integration,
raise_on_failure=True,
)
assert "Unable to import test_domain: No such file or directory" in caplog.text
assert "Unable to import test_domain: No such file or directory" in str(ex.value)
@pytest.mark.parametrize(
("exception_info_list", "error", "messages", "show_stack_trace", "translation_key"),
[
(
[
config_util.ConfigExceptionInfo(
ImportError("bla"),
"component_import_err",
"test_domain",
{"test_domain": []},
"https://example.com",
)
],
"bla",
["Unable to import test_domain: bla", "bla"],
False,
"component_import_err",
),
(
[
config_util.ConfigExceptionInfo(
HomeAssistantError("bla"),
"config_validation_err",
"test_domain",
{"test_domain": []},
"https://example.com",
)
],
"bla",
[
"Invalid config for 'test_domain': bla, "
"please check the docs at https://example.com",
"bla",
],
True,
"config_validation_err",
),
(
[
config_util.ConfigExceptionInfo(
vol.Invalid("bla", ["path"]),
"config_validation_err",
"test_domain",
{"test_domain": []},
"https://example.com",
)
],
"bla @ data['path']",
[
"Invalid config for 'test_domain': bla 'path', got None, "
"please check the docs at https://example.com",
"bla",
],
False,
"config_validation_err",
),
(
[
config_util.ConfigExceptionInfo(
vol.Invalid("bla", ["path"]),
"platform_config_validation_err",
"test_domain",
{"test_domain": []},
"https://alt.example.com",
)
],
"bla @ data['path']",
[
"Invalid config for 'test_domain': bla 'path', got None, "
"please check the docs at https://alt.example.com",
"bla",
],
False,
"platform_config_validation_err",
),
(
[
config_util.ConfigExceptionInfo(
ImportError("bla"),
"platform_component_load_err",
"test_domain",
{"test_domain": []},
"https://example.com",
)
],
"bla",
["Platform error: test_domain - bla", "bla"],
False,
"platform_component_load_err",
),
],
)
async def test_component_config_error_processing(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
error: str,
exception_info_list: list[config_util.ConfigExceptionInfo],
messages: list[str],
show_stack_trace: bool,
translation_key: str,
) -> None:
"""Test component config error processing."""
test_integration = Mock(
domain="test_domain",
documentation="https://example.com",
get_platform=Mock(
return_value=Mock(
async_validate_config=AsyncMock(side_effect=ValueError("broken"))
)
),
)
with patch(
"homeassistant.config.async_process_component_config",
return_value=config_util.IntegrationConfigInfo(None, exception_info_list),
), pytest.raises(ConfigValidationError) as ex:
await config_util.async_process_component_and_handle_errors(
hass, {}, test_integration, raise_on_failure=True
)
records = [record for record in caplog.records if record.msg == messages[0]]
assert len(records) == 1
assert (records[0].exc_info is not None) == show_stack_trace
assert str(ex.value) == messages[0]
assert ex.value.translation_key == translation_key
assert ex.value.translation_domain == "homeassistant"
assert ex.value.translation_placeholders["domain"] == "test_domain"
assert all(message in caplog.text for message in messages)
caplog.clear()
with patch(
"homeassistant.config.async_process_component_config",
return_value=config_util.IntegrationConfigInfo(None, exception_info_list),
):
await config_util.async_process_component_and_handle_errors(
hass, {}, test_integration
)
assert all(message in caplog.text for message in messages)
@pytest.mark.parametrize(
@ -1713,7 +2045,7 @@ async def test_component_config_validation_error(
integration = await async_get_integration(
hass, domain_with_label.partition(" ")[0]
)
await config_util.async_process_component_config(
await config_util.async_process_component_and_handle_errors(
hass,
config,
integration=integration,
@ -1758,7 +2090,7 @@ async def test_component_config_validation_error_with_docs(
integration = await async_get_integration(
hass, domain_with_label.partition(" ")[0]
)
await config_util.async_process_component_config(
await config_util.async_process_component_and_handle_errors(
hass,
config,
integration=integration,

View File

@ -374,7 +374,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None:
)
with assert_setup_component(0, "switch"), patch(
"homeassistant.config.async_notify_setup_error"
"homeassistant.setup.async_notify_setup_error"
) as mock_notify:
assert await setup.async_setup_component(
hass,
@ -389,7 +389,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None:
hass.config.components.remove("switch")
with assert_setup_component(0), patch(
"homeassistant.config.async_notify_setup_error"
"homeassistant.setup.async_notify_setup_error"
) as mock_notify:
assert await setup.async_setup_component(
hass,
@ -410,7 +410,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None:
hass.config.components.remove("switch")
with assert_setup_component(1, "switch"), patch(
"homeassistant.config.async_notify_setup_error"
"homeassistant.setup.async_notify_setup_error"
) as mock_notify:
assert await setup.async_setup_component(
hass,