Improve print of line numbers when there are configuration errors (#103216)

* Improve print of line numbers when there are configuration errors

* Update alarm_control_panel test
This commit is contained in:
Erik Montnemery 2023-11-14 08:21:36 +01:00 committed by GitHub
parent 9241554d45
commit dedd3418a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 113 additions and 43 deletions

View file

@ -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}"

View file

@ -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",
),
(
{

View file

@ -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"},
),
],

View file

@ -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 <BASE_PATH>/fixtures/core/config/component_validation/basic/configuration.yaml, line 6).",
"Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See <BASE_PATH>/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 <BASE_PATH>/fixtures/core/config/component_validation/basic/configuration.yaml, line 20).",
"Invalid config for [iot_domain.non_adr_0007] at <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/fixtures/core/config/component_validation/basic_include/configuration.yaml, line 4).",
"Invalid config for [iot_domain.non_adr_0007] at <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/fixtures/core/config/component_validation/packages/configuration.yaml, line 11).",
"Invalid config for [iot_domain]: required key not provided @ data['platform']. Got None. (See <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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 <BASE_PATH>/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]