diff --git a/homeassistant/config.py b/homeassistant/config.py index abe14adb2ef..17a6f32336f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable, Sequence from contextlib import suppress +from functools import reduce import logging +import operator import os from pathlib import Path import re @@ -505,6 +507,77 @@ def async_log_exception( _LOGGER.error(message, exc_info=not is_friendly and ex) +def _get_annotation(item: Any) -> tuple[str, int | str] | None: + if not hasattr(item, "__config_file__"): + return None + + return (getattr(item, "__config_file__"), getattr(item, "__line__", "?")) + + +def _get_by_path(data: dict | list, items: list[str | int]) -> Any: + """Access a nested object in root by item sequence. + + Returns None in case of error. + """ + try: + return reduce(operator.getitem, items, data) # type: ignore[arg-type] + except (KeyError, IndexError, TypeError): + return None + + +def find_annotation( + config: dict | list, path: list[str | int] +) -> tuple[str, int | str] | None: + """Find file/line annotation for a node in config pointed to by path. + + If the node pointed to is a dict or list, prefer the annotation for the key in + the key/value pair defining the dict or list. + If the node is not annotated, try the parent node. + """ + + def find_annotation_for_key( + item: dict, path: list[str | int], tail: str | int + ) -> tuple[str, int | str] | None: + for key in item: + if key == tail: + if annotation := _get_annotation(key): + return annotation + break + return None + + def find_annotation_rec( + config: dict | list, path: list[str | int], tail: str | int | None + ) -> tuple[str, int | str] | None: + item = _get_by_path(config, path) + if isinstance(item, dict) and tail is not None: + if tail_annotation := find_annotation_for_key(item, path, tail): + return tail_annotation + + if ( + isinstance(item, (dict, list)) + and path + and ( + key_annotation := find_annotation_for_key( + _get_by_path(config, path[:-1]), path[:-1], path[-1] + ) + ) + ): + return key_annotation + + if annotation := _get_annotation(item): + return annotation + + if not path: + return None + + tail = path.pop() + if annotation := find_annotation_rec(config, path, tail): + return annotation + return _get_annotation(item) + + return find_annotation_rec(config, list(path), None) + + @callback def _format_config_error( ex: Exception, domain: str, config: dict, link: str | None = None @@ -514,30 +587,26 @@ def _format_config_error( This method must be run in the event loop. """ is_friendly = False - message = f"Invalid config for [{domain}]: " + message = f"Invalid config for [{domain}]" + if isinstance(ex, vol.Invalid): + if annotation := find_annotation(config, ex.path): + message += f" at {annotation[0]}, line {annotation[1]}: " + else: + message += ": " + if "extra keys not allowed" in ex.error_message: path = "->".join(str(m) for m in ex.path) message += ( - f"[{ex.path[-1]}] is an invalid option for [{domain}]. " - f"Check: {domain}->{path}." + f"'{ex.path[-1]}' is an invalid option for [{domain}], check: {path}" ) else: message += f"{humanize_error(config, ex)}." is_friendly = True else: + message += ": " message += str(ex) or repr(ex) - try: - domain_config = config.get(domain, config) - except AttributeError: - domain_config = config - - message += ( - f" (See {getattr(domain_config, '__config_file__', '?')}, " - f"line {getattr(domain_config, '__line__', '?')})." - ) - if domain != CONF_CORE and link: message += f" Please check the docs at {link}" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index dd4fa1d32a5..9210b9ea738 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -198,7 +198,7 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: "wibble": {"test_panel": "Invalid"}, } }, - "[wibble] is an invalid option", + "'wibble' is an invalid option", ), ( { diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index a62bd8b39e4..baec4ae04a9 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -81,9 +81,10 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: error = CheckConfigError( ( - "Invalid config for [homeassistant]: not a valid value for dictionary " - "value @ data['unit_system']. Got 'bad'. (See " - f"{hass.config.path(YAML_CONFIG_FILE)}, line 2)." + "Invalid config for [homeassistant] at " + f"{hass.config.path(YAML_CONFIG_FILE)}, line 2: " + "not a valid value for dictionary value @ data['unit_system']. Got " + "'bad'." ), "homeassistant", {"unit_system": "bad"}, @@ -190,9 +191,9 @@ async def test_component_import_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("component", "errors", "warnings", "message"), [ - ("frontend", 1, 0, "[blah] is an invalid option for [frontend]"), - ("http", 1, 0, "[blah] is an invalid option for [http]"), - ("logger", 0, 1, "[blah] is an invalid option for [logger]"), + ("frontend", 1, 0, "'blah' is an invalid option for [frontend]"), + ("http", 1, 0, "'blah' is an invalid option for [http]"), + ("logger", 0, 1, "'blah' is an invalid option for [logger]"), ], ) async def test_component_schema_error( @@ -274,21 +275,21 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: ( "blah:\n - platform: test\n option1: 123", 1, - "Invalid config for [blah.test]: expected str for dictionary value", + "expected str for dictionary value", {"option1": 123, "platform": "test"}, ), # Test the attached config is unvalidated (key old is removed by validator) ( "blah:\n - platform: test\n old: blah\n option1: 123", 1, - "Invalid config for [blah.test]: expected str for dictionary value", + "expected str for dictionary value", {"old": "blah", "option1": 123, "platform": "test"}, ), # Test base platform configuration error ( "blah:\n - paltfrom: test\n", 1, - "Invalid config for [blah]: required key not provided", + "required key not provided", {"paltfrom": "test"}, ), ], diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index e7afa47537a..fd5e084d8ce 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,46 +1,46 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 6).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 9).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic/configuration.yaml, line 20).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 9: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 16: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic/configuration.yaml, line 21: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[basic_include] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 5).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 6: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml, line 8: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/basic_include/configuration.yaml, line 3: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml, line 3: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[include_dir_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 2).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml, line 2: required key not provided @ data['platform']. Got None.", ]) # --- # name: test_component_config_validation_error[include_dir_merge_list] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 2).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml, line 5: required key not provided @ data['platform']. Got None.", ]) # --- # name: test_component_config_validation_error[packages] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 11).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages/configuration.yaml, line 16).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 12: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 16: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 23: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages/configuration.yaml, line 28: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ - "Invalid config for [iot_domain.non_adr_0007]: expected str for dictionary value @ data['option1']. Got 123. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 6).", - "Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9).", - "Invalid config for [adr_0007_2]: required key not provided @ data['adr_0007_2']['host']. Got None. (See ?, line ?).", - "Invalid config for [adr_0007_3]: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'. (See ?, line ?).", + "Invalid config for [iot_domain.non_adr_0007] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 7: expected str for dictionary value @ data['option1']. Got 123.", + "Invalid config for [iot_domain] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml, line 9: required key not provided @ data['platform']. Got None.", + "Invalid config for [adr_0007_2] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml, line 2: required key not provided @ data['adr_0007_2']['host']. Got None.", + "Invalid config for [adr_0007_3] at /fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml, line 4: expected int for dictionary value @ data['adr_0007_3']['port']. Got 'foo'.", ]) # --- # name: test_package_merge_error[packages]