Skip to content

Commit

Permalink
Improve print of line numbers when there are configuration errors (ho…
Browse files Browse the repository at this point in the history
…me-assistant#103216)

* Improve print of line numbers when there are configuration errors

* Update alarm_control_panel test
  • Loading branch information
emontnemery authored Nov 14, 2023
1 parent 9241554 commit dedd341
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 43 deletions.
95 changes: 82 additions & 13 deletions homeassistant/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
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
Expand Down Expand Up @@ -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
Expand All @@ -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}"

Expand Down
2 changes: 1 addition & 1 deletion tests/components/template/test_alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
(
{
Expand Down
19 changes: 10 additions & 9 deletions tests/helpers/test_check_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"},
),
],
Expand Down
40 changes: 20 additions & 20 deletions tests/snapshots/test_config.ambr
Original file line number Diff line number Diff line change
@@ -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]
Expand Down

0 comments on commit dedd341

Please sign in to comment.