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

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