From 998129261dfe019d723b1e3316adc5583f65e882 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 20 Nov 2023 22:08:59 +0000 Subject: [PATCH 01/16] Allow async_integration_yaml_config to raise --- .../components/homeassistant/scene.py | 5 +- .../components/homeassistant/strings.json | 30 ++ homeassistant/components/lovelace/__init__.py | 11 +- homeassistant/components/mqtt/__init__.py | 20 +- homeassistant/components/template/__init__.py | 6 +- homeassistant/config.py | 316 +++++++++--- homeassistant/exceptions.py | 25 + homeassistant/helpers/entity_component.py | 5 +- homeassistant/helpers/reload.py | 44 +- homeassistant/setup.py | 43 +- tests/common.py | 6 +- tests/helpers/test_reload.py | 47 +- tests/test_bootstrap.py | 4 +- tests/test_config.py | 484 +++++++++++++++--- 14 files changed, 863 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 4b694d2b97af0..6b20fb2e40d01 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -194,7 +194,10 @@ async def reload_config(call: ServiceCall) -> None: integration = await async_get_integration(hass, SCENE_DOMAIN) - conf = await conf_util.async_process_component_config(hass, config, integration) + conf, config_ex = await conf_util.async_process_component_config( + hass, config, integration + ) + conf_util.async_handle_component_config_errors(hass, integration, config_ex) if not (conf and platform): return diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index f14d9f8148cef..fc89d864bfa0a 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -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 component 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_config_validation_err": { + "message": "Invalid config for integration platform {domain}.{p_name} at file {config_file}, line {line}: {error}. Check the logs for more information." + }, + "platform_schema_validator_err": { + "message": "Unknown error validating config for {p_name} platform for {domain} component with PLATFORM_SCHEMA" + }, + "platform_validator_unknown_err": { + "message": "Unknown error validating {p_name} platform config with {domain} component platform schema." + }, "service_not_found": { "message": "Service {domain}.{service} not found." } diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 2c425bec78514..21ace62e8b021 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,11 @@ 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_handle_component_config_errors, + async_hass_config_yaml, + async_process_component_config, +) from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -85,7 +89,10 @@ async def reload_resources_service_handler(service_call: ServiceCall) -> None: integration = await async_get_integration(hass, DOMAIN) - config = await async_process_component_config(hass, conf, integration) + config, config_ex = await async_process_component_config( + hass, conf, integration + ) + async_handle_component_config_errors(hass, integration, config_ex) if config is None: raise HomeAssistantError("Config validation failed") diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 83e6dae55b16d..dd51b276715fb 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -25,7 +25,7 @@ ) 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_reload_service() -> None: 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) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 22919ac9e708e..d5a8330487d00 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -34,9 +34,11 @@ async def _reload_config(call: Event | ServiceCall) -> None: _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, config_ex = await conf_util.async_process_component_config( + hass, unprocessed_conf, integration ) + conf_util.async_handle_component_config_errors(hass, integration, config_ex) if conf is None: return diff --git a/homeassistant/config.py b/homeassistant/config.py index 6a840b0171440..4eaa6edf2c73d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,6 +4,7 @@ from collections import OrderedDict from collections.abc import Callable, Sequence from contextlib import suppress +from dataclasses import dataclass from functools import reduce import logging import operator @@ -54,7 +55,7 @@ __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 +67,13 @@ 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 +118,19 @@ """ +@dataclass +class ConfigExceptionInfo: + """Configuration exception info class.""" + + exception: Exception + translation_key: str + platform_name: str + config: ConfigType + log_message: str | None = None + integration_link: str | None = None + show_stack_trace: bool = False + + def _no_duplicate_auth_provider( configs: Sequence[dict[str, Any]] ) -> Sequence[dict[str, Any]]: @@ -1025,21 +1039,136 @@ async def merge_packages_config( return config +@callback +def async_handle_component_config_errors( + hass: HomeAssistant, + integration: Integration, + config_exception_info: list[ConfigExceptionInfo], + raise_on_failure: bool = False, +) -> 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. + """ + + platform_exception: ConfigExceptionInfo + config_error_messages: list[ + tuple[str, ConfigExceptionInfo, str, str, int | str] + ] = [] + general_error_messages: list[tuple[str, ConfigExceptionInfo]] = [] + domain = integration.domain + for platform_exception in config_exception_info: + link = platform_exception.integration_link or integration.documentation + ex = platform_exception.exception + p_name = platform_exception.platform_name + p_config = platform_exception.config + config_file: str = "?" + line: int | str = "?" + if (log_message := platform_exception.log_message) is None: + # No pre defined log_message is set, so we generate a message + if isinstance(ex, vol.Invalid): + log_message = format_schema_error(hass, ex, p_name, p_config, link) + if annotation := find_annotation(p_config, ex.path): + config_file, line = annotation + elif isinstance(ex, HomeAssistantError): + log_message = format_homeassistant_error( + hass, ex, p_name, p_config, link + ) + if annotation := find_annotation(p_config, [p_name]): + config_file, line = annotation + else: + log_message = str(ex) + annotation = find_annotation(p_config, [p_name]) + config_error_messages.append( + (domain, platform_exception, log_message, config_file, line) + ) + else: + general_error_messages.append((domain, platform_exception)) + _LOGGER.error( + log_message, + exc_info=platform_exception.exception + if platform_exception.show_stack_trace + else None, + ) + + if not raise_on_failure or not config_exception_info: + return + + placeholders: dict[str, str] + if len(config_error_messages) == 1 and not general_error_messages: + ( + domain, + platform_exception, + log_message, + config_file, + line, + ) = config_error_messages[0] + ex = platform_exception.exception + p_name = platform_exception.platform_name + translation_key = platform_exception.translation_key + placeholders = { + "domain": domain, + "p_name": p_name, + "error": str(ex), + "errors": str(len(config_exception_info)), + "config_file": config_file, + "line": str(line), + } + elif len(general_error_messages) == 1 and not config_error_messages: + domain, platform_exception = general_error_messages[0] + ex = platform_exception.exception + translation_key = platform_exception.translation_key + log_message = str(platform_exception.log_message) + placeholders = { + "domain": domain, + "error": str(ex), + "errors": str(len(config_exception_info)), + } + else: + translation_key = "integration_config_error" + errors = str(len(config_exception_info)) + log_message = ( + f"Failed to process component config for integration {integration.domain} " + f"due to multiple errors ({errors}), check the logs for more information." + ) + placeholders = { + "domain": integration.domain, + "errors": errors, + } + raise ConfigValidationError( + 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 -) -> ConfigType | None: - """Check component configuration and return processed configuration. + hass: HomeAssistant, + config: ConfigType, + integration: Integration, +) -> tuple[ConfigType | None, list[ConfigExceptionInfo]]: + """Check component configuration. - Returns None on error. + Returns processed configuration and exception information. This method must be run in the event loop. """ domain = integration.domain + 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 + log_message = f"Unable to import {domain}: {ex}" + ex_info = ConfigExceptionInfo( + ex, "component_import_err", domain, config, log_message + ) + config_exceptions.append(ex_info) + return None, config_exceptions # Check if the integration has a custom config validator config_validator = None @@ -1050,62 +1179,94 @@ 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 + log_message = f"Error importing config platform {domain}: {err}" + ex_info = ConfigExceptionInfo( + err, "config_platform_import_err", domain, config, log_message + ) + config_exceptions.append(ex_info) + return 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 (await config_validator.async_validate_config(hass, config)), [] + except vol.Invalid as ex: + ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) + config_exceptions.append(ex_info) + return None, config_exceptions + except HomeAssistantError as ex: + ex_info = ConfigExceptionInfo( + ex, + "config_validation_err", + platform_name=domain, + config=config, + show_stack_trace=True, ) - except (vol.Invalid, HomeAssistantError) as ex: - async_log_config_validator_error( - ex, domain, config, hass, integration.documentation + config_exceptions.append(ex_info) + return None, config_exceptions + except Exception as ex: # pylint: disable=broad-except + log_message = f"Unknown error calling {domain} config validator" + ex_info = ConfigExceptionInfo( + ex, + "config_validator_unknown_err", + domain, + config, + log_message, + show_stack_trace=True, ) - return None - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error calling %s config validator", domain) - return None + config_exceptions.append(ex_info) + return 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] + return component.CONFIG_SCHEMA(config), [] 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 + ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) + config_exceptions.append(ex_info) + return None, config_exceptions + except Exception as ex: # pylint: disable=broad-except + log_message = f"Unknown error calling {domain} CONFIG_SCHEMA" + ex_info = ConfigExceptionInfo( + ex, + "config_schema_unknown_err", + domain, + config, + log_message, + show_stack_trace=True, + ) + config_exceptions.append(ex_info) + return 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 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 + ex_info = ConfigExceptionInfo( + ex, "platform_config_validation_err", domain, p_config ) + config_exceptions.append(ex_info) continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating %s platform config with %s component" - " platform schema" - ), - p_name, - domain, + except Exception as ex: # pylint: disable=broad-except + log_message = ( + f"Unknown error validating {p_name} platform config with {domain} component" + " platform schema" + ) + ex_info = ConfigExceptionInfo( + ex, "platform_validator_unknown_err", platform_name, config, log_message ) + config_exceptions.append(ex_info) continue # Not all platform components follow same pattern for platforms @@ -1118,13 +1279,26 @@ 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) + log_message = f"Platform error: {domain} - {ex}" + ex_info = ConfigExceptionInfo( + ex, "platform_component_load_err", platform_name, p_config, log_message + ) + config_exceptions.append(ex_info) continue try: platform = p_integration.get_platform(domain) - except LOAD_EXCEPTIONS: - _LOGGER.exception("Platform error: %s", domain) + except LOAD_EXCEPTIONS as ex: + log_message = f"Platform error: {domain} - {ex}" + ex_info = ConfigExceptionInfo( + ex, + "platform_component_load_err", + platform_name, + p_config, + log_message, + show_stack_trace=True, + ) + config_exceptions.append(ex_info) continue # Validate platform specific schema @@ -1132,23 +1306,29 @@ async def async_process_component_config( # noqa: C901 try: p_validated = platform.PLATFORM_SCHEMA(p_config) except vol.Invalid as ex: - async_log_schema_error( + ex_info = ConfigExceptionInfo( ex, - f"{domain}.{p_name}", + "platform_config_validation_err", + platform_name, p_config, - hass, - p_integration.documentation, + integration_link=p_integration.documentation, ) + config_exceptions.append(ex_info) continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating config for %s platform for %s" - " component with PLATFORM_SCHEMA" - ), - p_name, - domain, + except Exception as ex: # pylint: disable=broad-except + log_message = ( + f"Unknown error validating config for {p_name} platform " + f"for {domain} component with PLATFORM_SCHEMA" + ) + ex_info = ConfigExceptionInfo( + ex, + "platform_schema_validator_err", + platform_name, + p_config, + log_message, + show_stack_trace=True, ) + config_exceptions.append(ex_info) continue platforms.append(p_validated) @@ -1158,7 +1338,7 @@ async def async_process_component_config( # noqa: C901 config = config_without_domain(config, domain) config[domain] = platforms - return config + return config, config_exceptions @callback @@ -1183,36 +1363,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. diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 262b0e338ff77..03c29508b1cac 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -26,6 +26,31 @@ def __init__( self.translation_placeholders = translation_placeholders +class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]): + """A validation exception occurred when validation 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.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ddd467592590f..6b6a289890a52 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -355,9 +355,12 @@ async def async_prepare_reload( integration = await async_get_integration(self.hass, self.domain) - processed_conf = await conf_util.async_process_component_config( + processed_conf, config_ex = await conf_util.async_process_component_config( self.hass, conf, integration ) + conf_util.async_handle_component_config_errors( + self.hass, integration, config_ex + ) if processed_conf is None: return None diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 6e719cdac24d7..13321fa63e000 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,7 +4,7 @@ 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,9 +60,10 @@ async def _resetup_platform( """Resetup a platform.""" integration = await async_get_integration(hass, platform_domain) - conf = await conf_util.async_process_component_config( + conf, config_ex = await conf_util.async_process_component_config( hass, unprocessed_config, integration ) + conf_util.async_handle_component_config_errors(hass, integration, config_ex) if not conf: return @@ -136,15 +137,48 @@ 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, config_ex = await conf_util.async_process_component_config( + hass, + await conf_util.async_hass_config_yaml(hass), + integration, ) + conf_util.async_handle_component_config_errors( + hass, integration, config_ex, raise_on_failure=raise_on_failure + ) + + return config @callback diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9b705b4735e30..df2cbd2e760d3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -11,14 +11,13 @@ 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,42 @@ DATA_DEPS_REQS = "deps_reqs_processed" +DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" + 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,9 +248,11 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: log_error(f"Unable to import component: {err}", err) return False - processed_config = await conf_util.async_process_component_config( - hass, config, integration - ) + ( + processed_config, + config_exceptions, + ) = await conf_util.async_process_component_config(hass, config, integration) + conf_util.async_handle_component_config_errors(hass, integration, config_exceptions) if processed_config is None: log_error("Invalid config.") diff --git a/tests/common.py b/tests/common.py index bc770fae2fec5..c2683bef0160f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -984,7 +984,9 @@ 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) + res, exceptions = await async_process_component_config( + hass, config_input, integration + ) config[domain_input] = None if res is None else res.get(domain_input) _LOGGER.debug( "Configuration for %s, Validated: %s, Original %s", @@ -992,7 +994,7 @@ async def mock_psc(hass, config_input, integration): config[domain_input], config_input.get(domain_input), ) - return res + return res, exceptions assert isinstance(config, dict) with patch("homeassistant.config.async_process_component_config", mock_psc): diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 9c3789a35538a..f5847f9eccfe7 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -3,10 +3,12 @@ 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,7 @@ async def setup_platform(*args): 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=(None, []) ): await hass.services.async_call( PLATFORM, @@ -208,8 +210,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"}]} + + +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. - assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + 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: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index f5e01e0c97b39..e6c85f7609843 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1011,7 +1011,9 @@ async def mock_async_get_integrations( 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=({}, []) + ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) await bootstrap.async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() diff --git a/tests/test_config.py b/tests/test_config.py index 448990429a124..cb8e664c643f0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -30,6 +30,7 @@ __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 @@ -364,6 +365,25 @@ async def async_validate_config( ) +async def help_async_integration_yaml_config( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, + raise_on_failure: bool = False, +) -> ConfigType | None: + """Help process component config and errors.""" + config, config_ex = await config_util.async_process_component_config( + hass, + config, + integration, + ) + config_util.async_handle_component_config_errors( + hass, integration, config_ex, raise_on_failure=raise_on_failure + ) + + return config + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1427,71 +1447,129 @@ 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 help_async_integration_yaml_config(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 help_async_integration_yaml_config( + 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" + + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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_config( + await help_async_integration_yaml_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")) - ) - ), - ), + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( 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" ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + await help_async_integration_yaml_config( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Unknown error validating test_platform platform config " + "with test_domain component platform schema" + ) in caplog.text + assert str(ex.value) == ( + "Unknown error validating test_platform platform config " + "with test_domain component 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 +1580,331 @@ async def test_component_config_exceptions( ) ), ): - assert await config_util.async_process_component_config( + assert await help_async_integration_yaml_config( 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( 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 help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( 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 help_async_integration_yaml_config( + 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"), + [ + ( + [ + config_util.ConfigExceptionInfo( + ValueError("bla"), + "config_error_translation_key", + "test_domain", + {"test_domain": []}, + ) + ], + "bla", + ["bla"], + False, + ), + ( + [ + config_util.ConfigExceptionInfo( + HomeAssistantError("bla"), + "config_error_translation_key", + "test_domain", + {"test_domain": []}, + ) + ], + "bla", + [ + "Invalid config for 'test_domain': bla, " + "please check the docs at https://example.com", + "bla", + ], + False, + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "config_error_translation_key", + "test_domain", + {"test_domain": []}, + ) + ], + "bla @ data['path']", + [ + "Invalid config for 'test_domain': bla 'path', got None, " + "please check the docs at https://example.com", + "bla", + ], + False, + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "config_error_translation_key", + "test_domain", + {"test_domain": []}, + integration_link="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, + ), + ( + [ + config_util.ConfigExceptionInfo( + ImportError("bla"), + "config_error_translation_key", + "test_domain", + {"test_domain": []}, + show_stack_trace=True, + ) + ], + "bla", + ["bla"], + True, + ), + ], +) +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, +) -> 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 pytest.raises(ConfigValidationError) as ex: + config_util.async_handle_component_config_errors( + hass, + test_integration, + exception_info_list, + 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 == "config_error_translation_key" + assert ex.value.translation_domain == "homeassistant" + assert ex.value.translation_placeholders == { + "domain": "test_domain", + "p_name": "test_domain", + "error": error, + "errors": "1", + "config_file": "?", + "line": "?", + } + assert all(message in caplog.text for message in messages) + + caplog.clear() + config_util.async_handle_component_config_errors( + hass, + test_integration, + exception_info_list, + ) + assert all(message in caplog.text for message in messages) @pytest.mark.parametrize( @@ -1713,7 +2055,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 help_async_integration_yaml_config( hass, config, integration=integration, @@ -1758,7 +2100,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 help_async_integration_yaml_config( hass, config, integration=integration, From 9beed41f54abcd2ece4b9edf4b9d698b9fac2793 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 20 Nov 2023 22:23:16 +0000 Subject: [PATCH 02/16] Docstr - split check --- homeassistant/config.py | 5 ++++- homeassistant/exceptions.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 4eaa6edf2c73d..ee393806de1ae 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1053,6 +1053,9 @@ def async_handle_component_config_errors( - Raise a ConfigValidationError if raise_on_failure is set. """ + if not config_exception_info: + return + platform_exception: ConfigExceptionInfo config_error_messages: list[ tuple[str, ConfigExceptionInfo, str, str, int | str] @@ -1093,7 +1096,7 @@ def async_handle_component_config_errors( else None, ) - if not raise_on_failure or not config_exception_info: + if not raise_on_failure: return placeholders: dict[str, str] diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 03c29508b1cac..8d5e2bbde950a 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -27,7 +27,7 @@ def __init__( class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]): - """A validation exception occurred when validation the configuration.""" + """A validation exception occurred when validating the configuration.""" def __init__( self, From d9f3bc2cadd534fdc257eedbdde7013c95d6e108 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 21 Nov 2023 13:11:00 +0000 Subject: [PATCH 03/16] Implement as wrapper, return dataclass --- .../components/homeassistant/scene.py | 3 +- homeassistant/components/lovelace/__init__.py | 6 +-- homeassistant/components/template/__init__.py | 3 +- homeassistant/config.py | 54 ++++++++++++------- homeassistant/helpers/entity_component.py | 5 +- homeassistant/helpers/reload.py | 15 ++---- homeassistant/setup.py | 10 ++-- tests/common.py | 5 +- tests/test_bootstrap.py | 3 +- tests/test_config.py | 35 ++++++------ 10 files changed, 68 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 6b20fb2e40d01..258970378b249 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -194,10 +194,9 @@ async def reload_config(call: ServiceCall) -> None: integration = await async_get_integration(hass, SCENE_DOMAIN) - conf, config_ex = await conf_util.async_process_component_config( + conf = await conf_util.async_process_component_and_handle_errors( hass, config, integration ) - conf_util.async_handle_component_config_errors(hass, integration, config_ex) if not (conf and platform): return diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 21ace62e8b021..daa44bf60be09 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -5,9 +5,8 @@ from homeassistant.components import frontend, websocket_api from homeassistant.config import ( - async_handle_component_config_errors, async_hass_config_yaml, - async_process_component_config, + async_process_component_and_handle_errors, ) from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -89,10 +88,9 @@ async def reload_resources_service_handler(service_call: ServiceCall) -> None: integration = await async_get_integration(hass, DOMAIN) - config, config_ex = await async_process_component_config( + config = await async_process_component_and_handle_errors( hass, conf, integration ) - async_handle_component_config_errors(hass, integration, config_ex) if config is None: raise HomeAssistantError("Config validation failed") diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index d5a8330487d00..d52dc0cf166d1 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -35,10 +35,9 @@ async def _reload_config(call: Event | ServiceCall) -> None: return integration = await async_get_integration(hass, DOMAIN) - conf, config_ex = await conf_util.async_process_component_config( + conf = await conf_util.async_process_component_and_handle_errors( hass, unprocessed_conf, integration ) - conf_util.async_handle_component_config_errors(hass, integration, config_ex) if conf is None: return diff --git a/homeassistant/config.py b/homeassistant/config.py index ee393806de1ae..a488e598160f5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -131,6 +131,14 @@ class ConfigExceptionInfo: show_stack_trace: bool = False +@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]]: @@ -1039,22 +1047,28 @@ async def merge_packages_config( return config -@callback -def async_handle_component_config_errors( +async def async_process_component_and_handle_errors( hass: HomeAssistant, + config: ConfigType, integration: Integration, - config_exception_info: list[ConfigExceptionInfo], raise_on_failure: bool = False, -) -> None: - """Handle component configuration errors from async_process_component_config. +) -> 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 + ) + processed_config = integration_config_info.config + config_exception_info = integration_config_info.exception_info_list if not config_exception_info: - return + return processed_config platform_exception: ConfigExceptionInfo config_error_messages: list[ @@ -1097,7 +1111,7 @@ def async_handle_component_config_errors( ) if not raise_on_failure: - return + return processed_config placeholders: dict[str, str] if len(config_error_messages) == 1 and not general_error_messages: @@ -1153,7 +1167,7 @@ async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, integration: Integration, -) -> tuple[ConfigType | None, list[ConfigExceptionInfo]]: +) -> IntegrationConfigInfo: """Check component configuration. Returns processed configuration and exception information. @@ -1171,7 +1185,7 @@ async def async_process_component_config( # noqa: C901 ex, "component_import_err", domain, config, log_message ) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) # Check if the integration has a custom config validator config_validator = None @@ -1187,17 +1201,19 @@ async def async_process_component_config( # noqa: C901 err, "config_platform_import_err", domain, config, log_message ) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) if config_validator is not None and hasattr( config_validator, "async_validate_config" ): try: - return (await config_validator.async_validate_config(hass, config)), [] + return IntegrationConfigInfo( + await config_validator.async_validate_config(hass, config), [] + ) except vol.Invalid as ex: ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) except HomeAssistantError as ex: ex_info = ConfigExceptionInfo( ex, @@ -1207,7 +1223,7 @@ async def async_process_component_config( # noqa: C901 show_stack_trace=True, ) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except log_message = f"Unknown error calling {domain} config validator" ex_info = ConfigExceptionInfo( @@ -1219,16 +1235,16 @@ async def async_process_component_config( # noqa: C901 show_stack_trace=True, ) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) # No custom config validator, proceed with schema validation if hasattr(component, "CONFIG_SCHEMA"): try: - return component.CONFIG_SCHEMA(config), [] + return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) except vol.Invalid as ex: ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) config_exceptions.append(ex_info) - return None, config_exceptions + return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except log_message = f"Unknown error calling {domain} CONFIG_SCHEMA" ex_info = ConfigExceptionInfo( @@ -1240,14 +1256,14 @@ async def async_process_component_config( # noqa: C901 show_stack_trace=True, ) config_exceptions.append(ex_info) - return None, config_exceptions + 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: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): @@ -1341,7 +1357,7 @@ async def async_process_component_config( # noqa: C901 config = config_without_domain(config, domain) config[domain] = platforms - return config, config_exceptions + return IntegrationConfigInfo(config, config_exceptions) @callback diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 6b6a289890a52..775d0934c3630 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -355,12 +355,9 @@ async def async_prepare_reload( integration = await async_get_integration(self.hass, self.domain) - processed_conf, config_ex = await conf_util.async_process_component_config( + processed_conf = await conf_util.async_process_component_and_handle_errors( self.hass, conf, integration ) - conf_util.async_handle_component_config_errors( - self.hass, integration, config_ex - ) if processed_conf is None: return None diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 13321fa63e000..42ebc2d086916 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -60,10 +60,9 @@ async def _resetup_platform( """Resetup a platform.""" integration = await async_get_integration(hass, platform_domain) - conf, config_ex = await conf_util.async_process_component_config( + conf = await conf_util.async_process_component_and_handle_errors( hass, unprocessed_config, integration ) - conf_util.async_handle_component_config_errors(hass, integration, config_ex) if not conf: return @@ -169,16 +168,10 @@ async def async_integration_yaml_config( ) -> ConfigType | None: """Fetch the latest yaml configuration for an integration.""" integration = await async_get_integration(hass, integration_name) - config, config_ex = 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 ) - conf_util.async_handle_component_config_errors( - hass, integration, config_ex, raise_on_failure=raise_on_failure - ) - - return config @callback diff --git a/homeassistant/setup.py b/homeassistant/setup.py index df2cbd2e760d3..1ca5953ab630e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -248,12 +248,10 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: log_error(f"Unable to import component: {err}", err) return False - ( - processed_config, - config_exceptions, - ) = await conf_util.async_process_component_config(hass, config, integration) - conf_util.async_handle_component_config_errors(hass, integration, config_exceptions) - + integration_config_info = await conf_util.async_process_component_config( + hass, config, integration + ) + processed_config = integration_config_info.config if processed_config is None: log_error("Invalid config.") return False diff --git a/tests/common.py b/tests/common.py index c2683bef0160f..06cdee06ec9f8 100644 --- a/tests/common.py +++ b/tests/common.py @@ -984,9 +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, exceptions = await async_process_component_config( + 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", @@ -994,7 +995,7 @@ async def mock_psc(hass, config_input, integration): config[domain_input], config_input.get(domain_input), ) - return res, exceptions + return integration_config_info assert isinstance(config, dict) with patch("homeassistant.config.async_process_component_config", mock_psc): diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e6c85f7609843..19529a2a07427 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1012,7 +1012,8 @@ async def mock_async_get_integrations( "homeassistant.setup.loader.async_get_integrations", side_effect=mock_async_get_integrations, ), patch( - "homeassistant.config.async_process_component_config", return_value=({}, []) + "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}, {}) diff --git a/tests/test_config.py b/tests/test_config.py index cb8e664c643f0..abfc3f8928a16 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -372,17 +372,10 @@ async def help_async_integration_yaml_config( raise_on_failure: bool = False, ) -> ConfigType | None: """Help process component config and errors.""" - config, config_ex = await config_util.async_process_component_config( - hass, - config, - integration, - ) - config_util.async_handle_component_config_errors( - hass, integration, config_ex, raise_on_failure=raise_on_failure + return await config_util.async_process_component_and_handle_errors( + hass, config, integration, raise_on_failure=raise_on_failure ) - return config - async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" @@ -1875,12 +1868,12 @@ async def test_component_config_error_processing( ) ), ) - with pytest.raises(ConfigValidationError) as ex: - config_util.async_handle_component_config_errors( - hass, - test_integration, - exception_info_list, - raise_on_failure=True, + 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 @@ -1899,11 +1892,13 @@ async def test_component_config_error_processing( assert all(message in caplog.text for message in messages) caplog.clear() - config_util.async_handle_component_config_errors( - hass, - test_integration, - exception_info_list, - ) + 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) From 539d6966baab419a92929b8449f7eab8778f154b Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 21 Nov 2023 13:37:48 +0000 Subject: [PATCH 04/16] Fix setup error handling --- homeassistant/setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1ca5953ab630e..5296730905afb 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -248,10 +248,9 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: log_error(f"Unable to import component: {err}", err) return False - integration_config_info = await conf_util.async_process_component_config( + processed_config = await conf_util.async_process_component_and_handle_errors( hass, config, integration ) - processed_config = integration_config_info.config if processed_config is None: log_error("Invalid config.") return False From 683d8d9058b7527ef44a84cc764f3275082dda23 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 21 Nov 2023 15:07:48 +0000 Subject: [PATCH 05/16] Fix reload test mock --- tests/helpers/test_reload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index f5847f9eccfe7..586dbc19eb8e3 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -141,7 +141,9 @@ async def setup_platform(*args): 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, From 9b2f40cc53b3bfedc2a2549e88e38d0c903fb30d Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 21 Nov 2023 19:56:56 +0000 Subject: [PATCH 06/16] Move log_messages to error handler --- .../components/homeassistant/strings.json | 3 + homeassistant/config.py | 105 ++++++++---------- tests/test_config.py | 41 ++++--- 3 files changed, 72 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index fc89d864bfa0a..e7872396e0737 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -159,6 +159,9 @@ "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 integration platform {domain}.{p_name} at file {config_file}, line {line}: {error}. Check the logs for more information." }, diff --git a/homeassistant/config.py b/homeassistant/config.py index a488e598160f5..0f74f4893f8bc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -126,9 +126,7 @@ class ConfigExceptionInfo: translation_key: str platform_name: str config: ConfigType - log_message: str | None = None integration_link: str | None = None - show_stack_trace: bool = False @dataclass @@ -1062,6 +1060,39 @@ async def async_process_component_and_handle_errors( Returns the integration config or `None`. """ + def _get_log_message_and_stack_print_pref( + translation_key: str, ex: Exception, domain: str, platform_name: str + ) -> tuple[str | None, bool]: + """Get message to log and print stack trace preference.""" + log_message_mapping: dict[str, tuple[str, bool]] = { + "component_import_err": (f"Unable to import {domain}: {ex}", False), + "config_platform_import_err": ( + f"Error importing config platform {domain}: {ex}", + False, + ), + "config_validator_unknown_err": ( + f"Unknown error calling {domain} config validator", + True, + ), + "config_schema_unknown_err": ( + f"Unknown error calling {domain} CONFIG_SCHEMA", + True, + ), + "platform_validator_unknown_err": ( + f"Unknown error validating {platform_name} platform config with {domain} " + "component platform schema", + True, + ), + "platform_component_load_err": (f"Platform error: {domain} - {ex}", False), + "platform_component_load_exc": (f"Platform error: {domain} - {ex}", True), + "platform_schema_validator_err": ( + f"Unknown error validating config for {platform_name} platform " + f"for {domain} component with PLATFORM_SCHEMA", + True, + ), + } + return log_message_mapping.get(translation_key, (None, False)) + integration_config_info = await async_process_component_config( hass, config, integration ) @@ -1083,7 +1114,10 @@ async def async_process_component_and_handle_errors( p_config = platform_exception.config config_file: str = "?" line: int | str = "?" - if (log_message := platform_exception.log_message) is None: + log_message, show_stack_trace = _get_log_message_and_stack_print_pref( + platform_exception.translation_key, ex, domain, p_name + ) + if log_message is None: # No pre defined log_message is set, so we generate a message if isinstance(ex, vol.Invalid): log_message = format_schema_error(hass, ex, p_name, p_config, link) @@ -1095,6 +1129,7 @@ async def async_process_component_and_handle_errors( ) if annotation := find_annotation(p_config, [p_name]): config_file, line = annotation + show_stack_trace = True else: log_message = str(ex) annotation = find_annotation(p_config, [p_name]) @@ -1105,9 +1140,7 @@ async def async_process_component_and_handle_errors( general_error_messages.append((domain, platform_exception)) _LOGGER.error( log_message, - exc_info=platform_exception.exception - if platform_exception.show_stack_trace - else None, + exc_info=platform_exception.exception if show_stack_trace else None, ) if not raise_on_failure: @@ -1137,7 +1170,6 @@ async def async_process_component_and_handle_errors( domain, platform_exception = general_error_messages[0] ex = platform_exception.exception translation_key = platform_exception.translation_key - log_message = str(platform_exception.log_message) placeholders = { "domain": domain, "error": str(ex), @@ -1155,7 +1187,7 @@ async def async_process_component_and_handle_errors( "errors": errors, } raise ConfigValidationError( - log_message, + str(log_message), [platform_exception.exception for platform_exception in config_exception_info], translation_domain="homeassistant", translation_key=translation_key, @@ -1180,10 +1212,7 @@ async def async_process_component_config( # noqa: C901 try: component = integration.get_component() except LOAD_EXCEPTIONS as ex: - log_message = f"Unable to import {domain}: {ex}" - ex_info = ConfigExceptionInfo( - ex, "component_import_err", domain, config, log_message - ) + ex_info = ConfigExceptionInfo(ex, "component_import_err", domain, config) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1196,9 +1225,8 @@ 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": - log_message = f"Error importing config platform {domain}: {err}" ex_info = ConfigExceptionInfo( - err, "config_platform_import_err", domain, config, log_message + err, "config_platform_import_err", domain, config ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1215,24 +1243,12 @@ async def async_process_component_config( # noqa: C901 config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except HomeAssistantError as ex: - ex_info = ConfigExceptionInfo( - ex, - "config_validation_err", - platform_name=domain, - config=config, - show_stack_trace=True, - ) + ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except - log_message = f"Unknown error calling {domain} config validator" ex_info = ConfigExceptionInfo( - ex, - "config_validator_unknown_err", - domain, - config, - log_message, - show_stack_trace=True, + ex, "config_validator_unknown_err", domain, config ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1246,14 +1262,8 @@ async def async_process_component_config( # noqa: C901 config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except - log_message = f"Unknown error calling {domain} CONFIG_SCHEMA" ex_info = ConfigExceptionInfo( - ex, - "config_schema_unknown_err", - domain, - config, - log_message, - show_stack_trace=True, + ex, "config_schema_unknown_err", domain, config ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1278,12 +1288,8 @@ async def async_process_component_config( # noqa: C901 config_exceptions.append(ex_info) continue except Exception as ex: # pylint: disable=broad-except - log_message = ( - f"Unknown error validating {p_name} platform config with {domain} component" - " platform schema" - ) ex_info = ConfigExceptionInfo( - ex, "platform_validator_unknown_err", platform_name, config, log_message + ex, "platform_validator_unknown_err", str(p_name), config ) config_exceptions.append(ex_info) continue @@ -1298,9 +1304,8 @@ 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: - log_message = f"Platform error: {domain} - {ex}" ex_info = ConfigExceptionInfo( - ex, "platform_component_load_err", platform_name, p_config, log_message + ex, "platform_component_load_err", platform_name, p_config ) config_exceptions.append(ex_info) continue @@ -1308,14 +1313,8 @@ async def async_process_component_config( # noqa: C901 try: platform = p_integration.get_platform(domain) except LOAD_EXCEPTIONS as ex: - log_message = f"Platform error: {domain} - {ex}" ex_info = ConfigExceptionInfo( - ex, - "platform_component_load_err", - platform_name, - p_config, - log_message, - show_stack_trace=True, + ex, "platform_component_load_exc", platform_name, p_config ) config_exceptions.append(ex_info) continue @@ -1335,17 +1334,11 @@ async def async_process_component_config( # noqa: C901 config_exceptions.append(ex_info) continue except Exception as ex: # pylint: disable=broad-except - log_message = ( - f"Unknown error validating config for {p_name} platform " - f"for {domain} component with PLATFORM_SCHEMA" - ) ex_info = ConfigExceptionInfo( ex, "platform_schema_validator_err", - platform_name, + p_name, p_config, - log_message, - show_stack_trace=True, ) config_exceptions.append(ex_info) continue diff --git a/tests/test_config.py b/tests/test_config.py index abfc3f8928a16..9e6239d7e6bc8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1518,6 +1518,7 @@ async def test_component_config_exceptions( assert str(ex.value) == "Unknown error calling test_domain CONFIG_SCHEMA" # component.PLATFORM_SCHEMA + caplog.clear() test_integration = Mock( domain="test_domain", get_platform=Mock(return_value=None), @@ -1767,26 +1768,27 @@ async def test_component_config_exceptions( @pytest.mark.parametrize( - ("exception_info_list", "error", "messages", "show_stack_trace"), + ("exception_info_list", "error", "messages", "show_stack_trace", "translation_key"), [ ( [ config_util.ConfigExceptionInfo( - ValueError("bla"), - "config_error_translation_key", + ImportError("bla"), + "component_import_err", "test_domain", {"test_domain": []}, ) ], "bla", - ["bla"], + ["Unable to import test_domain: bla", "bla"], False, + "component_import_err", ), ( [ config_util.ConfigExceptionInfo( HomeAssistantError("bla"), - "config_error_translation_key", + "config_validation_err", "test_domain", {"test_domain": []}, ) @@ -1797,13 +1799,14 @@ async def test_component_config_exceptions( "please check the docs at https://example.com", "bla", ], - False, + True, + "config_validation_err", ), ( [ config_util.ConfigExceptionInfo( vol.Invalid("bla", ["path"]), - "config_error_translation_key", + "config_validation_err", "test_domain", {"test_domain": []}, ) @@ -1815,12 +1818,13 @@ async def test_component_config_exceptions( "bla", ], False, + "config_validation_err", ), ( [ config_util.ConfigExceptionInfo( vol.Invalid("bla", ["path"]), - "config_error_translation_key", + "platform_config_validation_err", "test_domain", {"test_domain": []}, integration_link="https://alt.example.com", @@ -1833,20 +1837,21 @@ async def test_component_config_exceptions( "bla", ], False, + "platform_config_validation_err", ), ( [ config_util.ConfigExceptionInfo( ImportError("bla"), - "config_error_translation_key", + "platform_component_load_err", "test_domain", {"test_domain": []}, - show_stack_trace=True, ) ], "bla", - ["bla"], - True, + ["Platform error: test_domain - bla", "bla"], + False, + "platform_component_load_err", ), ], ) @@ -1857,6 +1862,7 @@ async def test_component_config_error_processing( 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( @@ -1879,16 +1885,9 @@ async def test_component_config_error_processing( 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 == "config_error_translation_key" + assert ex.value.translation_key == translation_key assert ex.value.translation_domain == "homeassistant" - assert ex.value.translation_placeholders == { - "domain": "test_domain", - "p_name": "test_domain", - "error": error, - "errors": "1", - "config_file": "?", - "line": "?", - } + assert ex.value.translation_placeholders["domain"] == "test_domain" assert all(message in caplog.text for message in messages) caplog.clear() From 4cec2f13200cecbc7434605b535c8e519c1e7402 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 21 Nov 2023 22:19:11 +0000 Subject: [PATCH 07/16] Remove unreachable code --- homeassistant/config.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 0f74f4893f8bc..88eb0fe70803d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,7 +13,7 @@ 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 @@ -1123,16 +1123,15 @@ def _get_log_message_and_stack_print_pref( log_message = format_schema_error(hass, ex, p_name, p_config, link) if annotation := find_annotation(p_config, ex.path): config_file, line = annotation - elif isinstance(ex, HomeAssistantError): + else: + if TYPE_CHECKING: + assert isinstance(ex, HomeAssistantError) log_message = format_homeassistant_error( hass, ex, p_name, p_config, link ) if annotation := find_annotation(p_config, [p_name]): config_file, line = annotation show_stack_trace = True - else: - log_message = str(ex) - annotation = find_annotation(p_config, [p_name]) config_error_messages.append( (domain, platform_exception, log_message, config_file, line) ) From a65164c7edb7397aa302c990675a442d047446ea Mon Sep 17 00:00:00 2001 From: jbouwh Date: Wed, 22 Nov 2023 07:13:07 +0000 Subject: [PATCH 08/16] Remove config test helper --- tests/test_config.py | 54 ++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 9e6239d7e6bc8..62d4dbf1559df 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -365,18 +365,6 @@ async def async_validate_config( ) -async def help_async_integration_yaml_config( - hass: HomeAssistant, - config: ConfigType, - integration: Integration, - raise_on_failure: bool = False, -) -> ConfigType | None: - """Help process component config and errors.""" - return await config_util.async_process_component_and_handle_errors( - hass, config, integration, raise_on_failure=raise_on_failure - ) - - async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1449,14 +1437,16 @@ async def test_component_config_exceptions( ), ) assert ( - await help_async_integration_yaml_config(hass, {}, integration=test_integration) + 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 help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {}, integration=test_integration, raise_on_failure=True ) assert "ValueError: broken" in caplog.text @@ -1476,14 +1466,14 @@ async def test_component_config_exceptions( ) caplog.clear() assert ( - await help_async_integration_yaml_config( + 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 help_async_integration_yaml_config( + 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) @@ -1498,7 +1488,7 @@ async def test_component_config_exceptions( ), ) assert ( - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {}, integration=test_integration, @@ -1508,7 +1498,7 @@ async def test_component_config_exceptions( ) assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text with pytest.raises(HomeAssistantError) as ex: - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {}, integration=test_integration, @@ -1529,7 +1519,7 @@ async def test_component_config_exceptions( ) ), ) - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1542,7 +1532,7 @@ async def test_component_config_exceptions( ) in caplog.text caplog.clear() with pytest.raises(HomeAssistantError) as ex: - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1574,7 +1564,7 @@ async def test_component_config_exceptions( ) ), ): - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1587,7 +1577,7 @@ async def test_component_config_exceptions( ) in caplog.text caplog.clear() with pytest.raises(HomeAssistantError) as ex: - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1603,7 +1593,7 @@ async def test_component_config_exceptions( " component with PLATFORM_SCHEMA" in caplog.text ) # Test multiple platform failures - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, { "test_domain": [ @@ -1625,7 +1615,7 @@ async def test_component_config_exceptions( ) in caplog.text caplog.clear() with pytest.raises(HomeAssistantError) as ex: - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, { "test_domain": [ @@ -1667,7 +1657,7 @@ async def test_component_config_exceptions( get_platform=Mock(side_effect=import_error) ), ): - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1679,7 +1669,7 @@ async def test_component_config_exceptions( ) caplog.clear() with pytest.raises(HomeAssistantError) as ex: - assert await help_async_integration_yaml_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, integration=test_integration, @@ -1709,7 +1699,7 @@ async def test_component_config_exceptions( ), ) assert ( - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, integration=test_integration, @@ -1722,7 +1712,7 @@ async def test_component_config_exceptions( in caplog.text ) with pytest.raises(HomeAssistantError) as ex: - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, integration=test_integration, @@ -1747,7 +1737,7 @@ async def test_component_config_exceptions( ), ) assert ( - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, integration=test_integration, @@ -1757,7 +1747,7 @@ async def test_component_config_exceptions( ) assert "Unable to import test_domain: No such file or directory" in caplog.text with pytest.raises(HomeAssistantError) as ex: - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, integration=test_integration, @@ -2049,7 +2039,7 @@ async def test_component_config_validation_error( integration = await async_get_integration( hass, domain_with_label.partition(" ")[0] ) - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, config, integration=integration, @@ -2094,7 +2084,7 @@ async def test_component_config_validation_error_with_docs( integration = await async_get_integration( hass, domain_with_label.partition(" ")[0] ) - await help_async_integration_yaml_config( + await config_util.async_process_component_and_handle_errors( hass, config, integration=integration, From 58cf8c796a010410e8ab418fa35f1718a8f89c48 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Wed, 22 Nov 2023 23:01:58 +0000 Subject: [PATCH 09/16] Refactor and ensure notifications during setup --- homeassistant/config.py | 186 +++++++++++++++++++--------------------- homeassistant/setup.py | 11 ++- 2 files changed, 100 insertions(+), 97 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 88eb0fe70803d..409877d4f998e 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -127,6 +127,7 @@ class ConfigExceptionInfo: platform_name: str config: ConfigType integration_link: str | None = None + notify: bool = False @dataclass @@ -1045,6 +1046,40 @@ async def merge_packages_config( return config +def _get_log_message_and_stack_print_pref( + translation_key: str, ex: Exception, domain: str, platform_name: str +) -> tuple[str | None, bool]: + """Get message to log and print stack trace preference.""" + log_message_mapping: dict[str, tuple[str, bool]] = { + "component_import_err": (f"Unable to import {domain}: {ex}", False), + "config_platform_import_err": ( + f"Error importing config platform {domain}: {ex}", + False, + ), + "config_validator_unknown_err": ( + f"Unknown error calling {domain} config validator", + True, + ), + "config_schema_unknown_err": ( + f"Unknown error calling {domain} CONFIG_SCHEMA", + True, + ), + "platform_validator_unknown_err": ( + f"Unknown error validating {platform_name} platform config with {domain} " + "component platform schema", + True, + ), + "platform_component_load_err": (f"Platform error: {domain} - {ex}", False), + "platform_component_load_exc": (f"Platform error: {domain} - {ex}", True), + "platform_schema_validator_err": ( + f"Unknown error validating config for {platform_name} platform " + f"for {domain} component with PLATFORM_SCHEMA", + True, + ), + } + return log_message_mapping.get(translation_key, (None, False)) + + async def async_process_component_and_handle_errors( hass: HomeAssistant, config: ConfigType, @@ -1059,121 +1094,79 @@ async def async_process_component_and_handle_errors( Returns the integration config or `None`. """ - - def _get_log_message_and_stack_print_pref( - translation_key: str, ex: Exception, domain: str, platform_name: str - ) -> tuple[str | None, bool]: - """Get message to log and print stack trace preference.""" - log_message_mapping: dict[str, tuple[str, bool]] = { - "component_import_err": (f"Unable to import {domain}: {ex}", False), - "config_platform_import_err": ( - f"Error importing config platform {domain}: {ex}", - False, - ), - "config_validator_unknown_err": ( - f"Unknown error calling {domain} config validator", - True, - ), - "config_schema_unknown_err": ( - f"Unknown error calling {domain} CONFIG_SCHEMA", - True, - ), - "platform_validator_unknown_err": ( - f"Unknown error validating {platform_name} platform config with {domain} " - "component platform schema", - True, - ), - "platform_component_load_err": (f"Platform error: {domain} - {ex}", False), - "platform_component_load_exc": (f"Platform error: {domain} - {ex}", True), - "platform_schema_validator_err": ( - f"Unknown error validating config for {platform_name} platform " - f"for {domain} component with PLATFORM_SCHEMA", - True, - ), - } - return log_message_mapping.get(translation_key, (None, False)) - 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`. + """ + processed_config = integration_config_info.config config_exception_info = integration_config_info.exception_info_list if not config_exception_info: return processed_config platform_exception: ConfigExceptionInfo - config_error_messages: list[ - tuple[str, ConfigExceptionInfo, str, str, int | str] - ] = [] - general_error_messages: list[tuple[str, ConfigExceptionInfo]] = [] domain = integration.domain + placeholders: dict[str, str] = { + "domain": domain, + } for platform_exception in config_exception_info: - link = platform_exception.integration_link or integration.documentation - ex = platform_exception.exception - p_name = platform_exception.platform_name - p_config = platform_exception.config - config_file: str = "?" - line: int | str = "?" + exception = platform_exception.exception + placeholders["error"] = str(exception) + platform_name = placeholders["p_name"] = platform_exception.platform_name + platform_config = platform_exception.config log_message, show_stack_trace = _get_log_message_and_stack_print_pref( - platform_exception.translation_key, ex, domain, p_name + platform_exception.translation_key, exception, domain, platform_name ) + link = platform_exception.integration_link or integration.documentation if log_message is None: - # No pre defined log_message is set, so we generate a message - if isinstance(ex, vol.Invalid): - log_message = format_schema_error(hass, ex, p_name, p_config, link) - if annotation := find_annotation(p_config, ex.path): - config_file, line = annotation + # If no pre defined log_message is set, we generate an enriched error + # message, so we can notify about it during setup + 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(ex, HomeAssistantError) + assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, ex, p_name, p_config, link + hass, exception, platform_name, platform_config, link ) - if annotation := find_annotation(p_config, [p_name]): - config_file, line = annotation + if annotation := find_annotation(platform_config, [platform_name]): + placeholders["config_file"], line = annotation + placeholders["line"] = str(line) show_stack_trace = True - config_error_messages.append( - (domain, platform_exception, log_message, config_file, line) - ) - else: - general_error_messages.append((domain, platform_exception)) _LOGGER.error( log_message, - exc_info=platform_exception.exception if show_stack_trace else None, + exc_info=exception if show_stack_trace else None, ) if not raise_on_failure: return processed_config - placeholders: dict[str, str] - if len(config_error_messages) == 1 and not general_error_messages: - ( - domain, - platform_exception, - log_message, - config_file, - line, - ) = config_error_messages[0] - ex = platform_exception.exception - p_name = platform_exception.platform_name + if len(config_exception_info) == 1: translation_key = platform_exception.translation_key - placeholders = { - "domain": domain, - "p_name": p_name, - "error": str(ex), - "errors": str(len(config_exception_info)), - "config_file": config_file, - "line": str(line), - } - elif len(general_error_messages) == 1 and not config_error_messages: - domain, platform_exception = general_error_messages[0] - ex = platform_exception.exception - translation_key = platform_exception.translation_key - placeholders = { - "domain": domain, - "error": str(ex), - "errors": str(len(config_exception_info)), - } else: translation_key = "integration_config_error" errors = str(len(config_exception_info)) @@ -1237,12 +1230,10 @@ async def async_process_component_config( # noqa: C901 return IntegrationConfigInfo( await config_validator.async_validate_config(hass, config), [] ) - except vol.Invalid as ex: - ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) - config_exceptions.append(ex_info) - return IntegrationConfigInfo(None, config_exceptions) - except HomeAssistantError as ex: - ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) + except (vol.Invalid, HomeAssistantError) as ex: + ex_info = ConfigExceptionInfo( + ex, "config_validation_err", domain, config, notify=True + ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except @@ -1257,7 +1248,9 @@ async def async_process_component_config( # noqa: C901 try: return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) except vol.Invalid as ex: - ex_info = ConfigExceptionInfo(ex, "config_validation_err", domain, config) + ex_info = ConfigExceptionInfo( + ex, "config_validation_err", domain, config, notify=True + ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except @@ -1282,7 +1275,7 @@ async def async_process_component_config( # noqa: C901 p_validated = component_platform_schema(p_config) except vol.Invalid as ex: ex_info = ConfigExceptionInfo( - ex, "platform_config_validation_err", domain, p_config + ex, "platform_config_validation_err", domain, p_config, notify=True ) config_exceptions.append(ex_info) continue @@ -1329,6 +1322,7 @@ async def async_process_component_config( # noqa: C901 platform_name, p_config, integration_link=p_integration.documentation, + notify=True, ) config_exceptions.append(ex_info) continue diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5296730905afb..03c0fe8036674 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -248,9 +248,18 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: log_error(f"Unable to import component: {err}", err) return False - processed_config = await conf_util.async_process_component_and_handle_errors( + 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 not platform_exception.notify: + 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 From 9bb07c9e222bee89f872441c1d0e3e13e2a7a11a Mon Sep 17 00:00:00 2001 From: jbouwh Date: Wed, 22 Nov 2023 23:41:31 +0000 Subject: [PATCH 10/16] Remove redundat error, adjust tests notifications --- homeassistant/components/homeassistant/strings.json | 3 --- homeassistant/config.py | 2 +- tests/test_bootstrap.py | 2 +- tests/test_config.py | 12 ++++++------ tests/test_setup.py | 6 +++--- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e7872396e0737..1811b0dfd035a 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -168,9 +168,6 @@ "platform_schema_validator_err": { "message": "Unknown error validating config for {p_name} platform for {domain} component with PLATFORM_SCHEMA" }, - "platform_validator_unknown_err": { - "message": "Unknown error validating {p_name} platform config with {domain} component platform schema." - }, "service_not_found": { "message": "Service {domain}.{service} not found." } diff --git a/homeassistant/config.py b/homeassistant/config.py index 409877d4f998e..63fd5f820645a 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1281,7 +1281,7 @@ async def async_process_component_config( # noqa: C901 continue except Exception as ex: # pylint: disable=broad-except ex_info = ConfigExceptionInfo( - ex, "platform_validator_unknown_err", str(p_name), config + ex, "platform_schema_validator_err", str(p_name), config ) config_exceptions.append(ex_info) continue diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index f6d3b92bb4aaf..8ab6fa8f99f5b 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -719,7 +719,7 @@ async def test_setup_hass_invalid_core_config( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" - with patch("homeassistant.config.async_notify_setup_error") as mock_notify: + with patch("homeassistant.setup.async_notify_setup_error") as mock_notify: hass = await bootstrap.async_setup_hass( runner.RuntimeConfig( config_dir=get_test_config_dir(), diff --git a/tests/test_config.py b/tests/test_config.py index 62d4dbf1559df..0e9800a3d3339 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1527,8 +1527,8 @@ async def test_component_config_exceptions( ) == {"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: @@ -1539,12 +1539,12 @@ async def test_component_config_exceptions( raise_on_failure=True, ) 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 assert str(ex.value) == ( - "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" ) # platform.PLATFORM_SCHEMA diff --git a/tests/test_setup.py b/tests/test_setup.py index 0f480198c11f6..00bb3fa2a2dfe 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -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, From 9ebf8ebb882901a1bd42c60d9854182a67543608 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Thu, 23 Nov 2023 09:35:50 +0000 Subject: [PATCH 11/16] Fix patch --- tests/test_bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 8ab6fa8f99f5b..f6d3b92bb4aaf 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -719,7 +719,7 @@ async def test_setup_hass_invalid_core_config( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" - with patch("homeassistant.setup.async_notify_setup_error") as mock_notify: + with patch("homeassistant.config.async_notify_setup_error") as mock_notify: hass = await bootstrap.async_setup_hass( runner.RuntimeConfig( config_dir=get_test_config_dir(), From 9dbfd3de1a5f5355e4a00777eefc5cea5df334a9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Nov 2023 19:38:14 +0100 Subject: [PATCH 12/16] Apply suggestions from code review Co-authored-by: Erik Montnemery --- homeassistant/components/homeassistant/strings.json | 4 ++-- homeassistant/config.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 1811b0dfd035a..f53dd4673c523 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -163,10 +163,10 @@ "message": "Platform error: {domain} - {error}. Check the logs for more information." }, "platform_config_validation_err": { - "message": "Invalid config for integration platform {domain}.{p_name} at file {config_file}, line {line}: {error}. Check the logs for more information." + "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 validating config for {p_name} platform for {domain} component with PLATFORM_SCHEMA" + "message": "Unknown error when validating config for {domain} from integration {p_name}" }, "service_not_found": { "message": "Service {domain}.{service} not found." diff --git a/homeassistant/config.py b/homeassistant/config.py index 63fd5f820645a..9ac95403dcdd4 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -126,7 +126,7 @@ class ConfigExceptionInfo: translation_key: str platform_name: str config: ConfigType - integration_link: str | None = None + integration_link: str | None notify: bool = False From 4f95b2feb788280506cf939ab20ecd0b2e0cac81 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Thu, 23 Nov 2023 20:25:56 +0000 Subject: [PATCH 13/16] Follow up comments --- .../components/homeassistant/strings.json | 2 +- homeassistant/config.py | 215 ++++++++++++------ homeassistant/setup.py | 7 +- tests/test_config.py | 6 +- 4 files changed, 160 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index f53dd4673c523..6981bdfe685f0 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -154,7 +154,7 @@ "message": "Unknown error calling {domain} CONFIG_SCHEMA. Check the logs for more information." }, "integration_config_error": { - "message": "Failed to process component config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information." + "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." diff --git a/homeassistant/config.py b/homeassistant/config.py index 9ac95403dcdd4..e025a1ee160eb 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -5,6 +5,7 @@ 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 @@ -118,16 +119,36 @@ """ +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: str + translation_key: ConfigErrorTranslationKey platform_name: str config: ConfigType integration_link: str | None - notify: bool = False @dataclass @@ -1047,37 +1068,80 @@ async def merge_packages_config( def _get_log_message_and_stack_print_pref( - translation_key: str, ex: Exception, domain: str, platform_name: str -) -> tuple[str | None, bool]: + hass: HomeAssistant, domain: str, platform_exception: ConfigExceptionInfo +) -> tuple[str | None, bool, dict[str, str]]: """Get message to log and print stack trace preference.""" - log_message_mapping: dict[str, tuple[str, bool]] = { - "component_import_err": (f"Unable to import {domain}: {ex}", False), - "config_platform_import_err": ( - f"Error importing config platform {domain}: {ex}", + translation_key = platform_exception.translation_key + exception = platform_exception.exception + platform_name = platform_exception.platform_name + platform_config = platform_exception.config + link = platform_exception.integration_link + + 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, ), - "config_validator_unknown_err": ( + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR: ( f"Unknown error calling {domain} config validator", True, ), - "config_schema_unknown_err": ( + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR: ( f"Unknown error calling {domain} CONFIG_SCHEMA", True, ), - "platform_validator_unknown_err": ( + ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( f"Unknown error validating {platform_name} platform config with {domain} " "component platform schema", True, ), - "platform_component_load_err": (f"Platform error: {domain} - {ex}", False), - "platform_component_load_exc": (f"Platform error: {domain} - {ex}", True), - "platform_schema_validator_err": ( + 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, ), } - return log_message_mapping.get(translation_key, (None, False)) + log_message_show_stack_trace = log_message_mapping.get(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( @@ -1118,64 +1182,38 @@ def async_handle_component_errors( Returns the integration config or `None`. """ - processed_config = integration_config_info.config - config_exception_info = integration_config_info.exception_info_list - if not config_exception_info: - return processed_config + 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] = { - "domain": domain, - } + placeholders: dict[str, str] for platform_exception in config_exception_info: exception = platform_exception.exception - placeholders["error"] = str(exception) - platform_name = placeholders["p_name"] = platform_exception.platform_name - platform_config = platform_exception.config - log_message, show_stack_trace = _get_log_message_and_stack_print_pref( - platform_exception.translation_key, exception, domain, platform_name - ) - link = platform_exception.integration_link or integration.documentation - if log_message is None: - # If no pre defined log_message is set, we generate an enriched error - # message, so we can notify about it during setup - 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 + ( + 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 processed_config + return integration_config_info.config if len(config_exception_info) == 1: translation_key = platform_exception.translation_key else: - translation_key = "integration_config_error" + translation_key = ConfigErrorTranslationKey.INTEGRATION_CONFIG_ERROR errors = str(len(config_exception_info)) log_message = ( - f"Failed to process component config for integration {integration.domain} " + f"Failed to process component config for integration {domain} " f"due to multiple errors ({errors}), check the logs for more information." ) placeholders = { - "domain": integration.domain, + "domain": domain, "errors": errors, } raise ConfigValidationError( @@ -1199,12 +1237,19 @@ async def async_process_component_config( # noqa: C901 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: - ex_info = ConfigExceptionInfo(ex, "component_import_err", domain, config) + ex_info = ConfigExceptionInfo( + ex, + ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR, + domain, + config, + integration_docs, + ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1218,7 +1263,11 @@ async def async_process_component_config( # noqa: C901 # that still fails. if err.name != f"{integration.pkg_path}.config": ex_info = ConfigExceptionInfo( - err, "config_platform_import_err", domain, config + err, + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, + domain, + config, + integration_docs, ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1232,13 +1281,21 @@ async def async_process_component_config( # noqa: C901 ) except (vol.Invalid, HomeAssistantError) as ex: ex_info = ConfigExceptionInfo( - ex, "config_validation_err", domain, config, notify=True + ex, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except ex_info = ConfigExceptionInfo( - ex, "config_validator_unknown_err", domain, config + ex, + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR, + domain, + config, + integration_docs, ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1249,13 +1306,21 @@ async def async_process_component_config( # noqa: C901 return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) except vol.Invalid as ex: ex_info = ConfigExceptionInfo( - ex, "config_validation_err", domain, config, notify=True + ex, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) except Exception as ex: # pylint: disable=broad-except ex_info = ConfigExceptionInfo( - ex, "config_schema_unknown_err", domain, config + ex, + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR, + domain, + config, + integration_docs, ) config_exceptions.append(ex_info) return IntegrationConfigInfo(None, config_exceptions) @@ -1275,13 +1340,21 @@ async def async_process_component_config( # noqa: C901 p_validated = component_platform_schema(p_config) except vol.Invalid as ex: ex_info = ConfigExceptionInfo( - ex, "platform_config_validation_err", domain, p_config, notify=True + ex, + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, + domain, + p_config, + integration_docs, ) config_exceptions.append(ex_info) continue except Exception as ex: # pylint: disable=broad-except ex_info = ConfigExceptionInfo( - ex, "platform_schema_validator_err", str(p_name), config + ex, + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, + str(p_name), + config, + integration_docs, ) config_exceptions.append(ex_info) continue @@ -1297,7 +1370,11 @@ async def async_process_component_config( # noqa: C901 p_integration = await async_get_integration_with_requirements(hass, p_name) except (RequirementsNotFound, IntegrationNotFound) as ex: ex_info = ConfigExceptionInfo( - ex, "platform_component_load_err", platform_name, p_config + ex, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, + platform_name, + p_config, + integration_docs, ) config_exceptions.append(ex_info) continue @@ -1306,7 +1383,11 @@ async def async_process_component_config( # noqa: C901 platform = p_integration.get_platform(domain) except LOAD_EXCEPTIONS as ex: ex_info = ConfigExceptionInfo( - ex, "platform_component_load_exc", platform_name, p_config + ex, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, + platform_name, + p_config, + integration_docs, ) config_exceptions.append(ex_info) continue @@ -1318,20 +1399,20 @@ async def async_process_component_config( # noqa: C901 except vol.Invalid as ex: ex_info = ConfigExceptionInfo( ex, - "platform_config_validation_err", + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, platform_name, p_config, - integration_link=p_integration.documentation, - notify=True, + p_integration.documentation, ) config_exceptions.append(ex_info) continue except Exception as ex: # pylint: disable=broad-except ex_info = ConfigExceptionInfo( ex, - "platform_schema_validator_err", + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, p_name, p_config, + p_integration.documentation, ) config_exceptions.append(ex_info) continue diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 03c0fe8036674..679042bc4e9bf 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -57,6 +57,11 @@ 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 @@ -255,7 +260,7 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: hass, integration_config_info, integration ) for platform_exception in integration_config_info.exception_info_list: - if not platform_exception.notify: + 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 diff --git a/tests/test_config.py b/tests/test_config.py index 0e9800a3d3339..39c9229f68a7f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1767,6 +1767,7 @@ async def test_component_config_exceptions( "component_import_err", "test_domain", {"test_domain": []}, + "https://example.com", ) ], "bla", @@ -1781,6 +1782,7 @@ async def test_component_config_exceptions( "config_validation_err", "test_domain", {"test_domain": []}, + "https://example.com", ) ], "bla", @@ -1799,6 +1801,7 @@ async def test_component_config_exceptions( "config_validation_err", "test_domain", {"test_domain": []}, + "https://example.com", ) ], "bla @ data['path']", @@ -1817,7 +1820,7 @@ async def test_component_config_exceptions( "platform_config_validation_err", "test_domain", {"test_domain": []}, - integration_link="https://alt.example.com", + "https://alt.example.com", ) ], "bla @ data['path']", @@ -1836,6 +1839,7 @@ async def test_component_config_exceptions( "platform_component_load_err", "test_domain", {"test_domain": []}, + "https://example.com", ) ], "bla", From d09789e381159b682518b2148b2b78fdc07c80d4 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Thu, 23 Nov 2023 21:23:32 +0000 Subject: [PATCH 14/16] Add call_back decorator --- homeassistant/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index e025a1ee160eb..4441f18cb6b11 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1067,11 +1067,11 @@ async def merge_packages_config( return config +@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.""" - translation_key = platform_exception.translation_key exception = platform_exception.exception platform_name = platform_exception.platform_name platform_config = platform_exception.config @@ -1115,7 +1115,9 @@ def _get_log_message_and_stack_print_pref( True, ), } - log_message_show_stack_trace = log_message_mapping.get(translation_key) + 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 From 592c854396fe1a5691c5a39174e6f60dc272ed1e Mon Sep 17 00:00:00 2001 From: jbouwh Date: Fri, 24 Nov 2023 13:56:52 +0000 Subject: [PATCH 15/16] Split long lines --- tests/test_config.py | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 39c9229f68a7f..de5e7e0581d0f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1606,12 +1606,12 @@ async def test_component_config_exceptions( ) == {"test_domain": []} assert "ValueError: broken" in caplog.text assert ( - "Unknown error validating config for test_platform1 platform for test_domain" - " component with PLATFORM_SCHEMA" + "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" + "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: @@ -1632,12 +1632,12 @@ async def test_component_config_exceptions( ) 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" + "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" + "Unknown error validating config for test_platform2 platform " + "for test_domain component with PLATFORM_SCHEMA" ) in caplog.text # get_platform("domain") raising on ImportError @@ -1664,8 +1664,8 @@ async def test_component_config_exceptions( raise_on_failure=False, ) == {"test_domain": []} assert ( - "ImportError: ModuleNotFoundError: No module named 'not_installed_something'" - in caplog.text + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text ) caplog.clear() with pytest.raises(HomeAssistantError) as ex: @@ -1676,14 +1676,16 @@ async def test_component_config_exceptions( raise_on_failure=True, ) assert ( - "ImportError: ModuleNotFoundError: No module named 'not_installed_something'" - in caplog.text + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text ) assert ( - "Platform error: test_domain - ModuleNotFoundError: No module named 'not_installed_something'" + "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'" + "Platform error: test_domain - ModuleNotFoundError: " + "No module named 'not_installed_something'" ) in str(ex.value) # get_platform("config") raising @@ -1708,8 +1710,8 @@ async def test_component_config_exceptions( 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( @@ -1719,12 +1721,12 @@ async def test_component_config_exceptions( raise_on_failure=True, ) 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 ) assert ( - "Error importing config platform test_domain: ModuleNotFoundError: No module named 'not_installed_something'" - in str(ex.value) + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in str(ex.value) ) # get_component raising From b06d646a7e5d079ccdc4f00dc2cf24f774ad9509 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Fri, 24 Nov 2023 14:12:35 +0000 Subject: [PATCH 16/16] Update exception abbreviations --- homeassistant/config.py | 92 ++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 4441f18cb6b11..a9c505b0a6824 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1244,15 +1244,15 @@ async def async_process_component_config( # noqa: C901 try: component = integration.get_component() - except LOAD_EXCEPTIONS as ex: - ex_info = ConfigExceptionInfo( - ex, + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) # Check if the integration has a custom config validator @@ -1264,14 +1264,14 @@ 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": - ex_info = ConfigExceptionInfo( + exc_info = ConfigExceptionInfo( err, ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) if config_validator is not None and hasattr( @@ -1281,50 +1281,50 @@ async def async_process_component_config( # noqa: C901 return IntegrationConfigInfo( await config_validator.async_validate_config(hass, config), [] ) - except (vol.Invalid, HomeAssistantError) as ex: - ex_info = ConfigExceptionInfo( - ex, + except (vol.Invalid, HomeAssistantError) as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) - except Exception as ex: # pylint: disable=broad-except - ex_info = ConfigExceptionInfo( - ex, + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + 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 IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) - except vol.Invalid as ex: - ex_info = ConfigExceptionInfo( - ex, + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) - except Exception as ex: # pylint: disable=broad-except - ex_info = ConfigExceptionInfo( - ex, + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR, domain, config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) component_platform_schema = getattr( @@ -1340,25 +1340,25 @@ async def async_process_component_config( # noqa: C901 platform_name = f"{domain}.{p_name}" try: p_validated = component_platform_schema(p_config) - except vol.Invalid as ex: - ex_info = ConfigExceptionInfo( - ex, + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, domain, p_config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) continue - except Exception as ex: # pylint: disable=broad-except - ex_info = ConfigExceptionInfo( - ex, + 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(ex_info) + config_exceptions.append(exc_info) continue # Not all platform components follow same pattern for platforms @@ -1370,53 +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: - ex_info = ConfigExceptionInfo( - ex, + except (RequirementsNotFound, IntegrationNotFound) as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, platform_name, p_config, integration_docs, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) continue try: platform = p_integration.get_platform(domain) - except LOAD_EXCEPTIONS as ex: - ex_info = ConfigExceptionInfo( - ex, + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, platform_name, p_config, integration_docs, ) - config_exceptions.append(ex_info) + 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: - ex_info = ConfigExceptionInfo( - ex, + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, platform_name, p_config, p_integration.documentation, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) continue - except Exception as ex: # pylint: disable=broad-except - ex_info = ConfigExceptionInfo( - ex, + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, p_name, p_config, p_integration.documentation, ) - config_exceptions.append(ex_info) + config_exceptions.append(exc_info) continue platforms.append(p_validated)