Files
esphome/tests/unit_tests/test_config_error_format.py
2026-10-11 13:33:18 +11:00

313 lines
9.2 KiB
Python

"""Tests for the `--error-format line` output of read_config."""
from pathlib import Path
import pytest
import voluptuous as vol
import yaml
from esphome import config_validation as cv
from esphome.config import (
Config,
InvalidYAMLError,
_print_line_errors,
_print_line_load_error,
_print_line_message,
_print_marked_yaml_error,
read_config,
)
from esphome.const import ErrorFormat
from esphome.core import CORE
from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid
def _read(
tmp_path: Path, files: dict[str, str], capsys: pytest.CaptureFixture[str]
) -> list[str]:
for name, content in files.items():
(tmp_path / name).write_text(content)
CORE.config_path = tmp_path / "test.yaml"
CORE.error_format = ErrorFormat.LINE
assert read_config({}) is None
return capsys.readouterr().out.splitlines()
def test_validation_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
lines = _read(
tmp_path,
{"test.yaml": "esphome:\n name: test\nhost:\nlogger:\n levl: DEBUG\n"},
capsys,
)
assert lines == [
(
f"{tmp_path / 'test.yaml'}:5:3: error: [levl] is an invalid option for "
"[logger]. Did you mean [level]?"
)
]
def test_validation_error_in_package(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n"
),
"pkg.yaml": "logger:\n levl: DEBUG\n",
},
capsys,
)
assert lines == [
(
f"{tmp_path / 'pkg.yaml'}:2:3: error: [levl] is an invalid option for "
"[logger]. Did you mean [level]?"
)
]
def test_unknown_action_anchors_on_the_key(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# validate_registry_entry raises KeyInvalid for an unknown action, so the
# location is the key, not the action's value.
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\nlogger:\nbutton:\n"
' - platform: template\n on_press:\n - loggr.log: "msg"\n'
)
},
capsys,
)
assert lines == [
(
f"{tmp_path / 'test.yaml'}:8:9: error: Unable to find action "
"with the name 'loggr.log'."
)
]
def test_yaml_syntax_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
lines = _read(tmp_path, {"test.yaml": "esphome:\n name: [1, 2\nhost:\n"}, capsys)
assert lines == [
f"{tmp_path / 'test.yaml'}:3:5: error: expected ',' or ']', but got ':'",
f"{tmp_path / 'test.yaml'}:2:9: note: while parsing a flow sequence",
]
def test_yaml_syntax_error_in_package(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n"
),
"pkg.yaml": "logger:\n level: [1, 2\nwifi:\n",
},
capsys,
)
assert lines == [
f"{tmp_path / 'pkg.yaml'}:3:5: error: expected ',' or ']', but got ':'",
f"{tmp_path / 'pkg.yaml'}:2:10: note: while parsing a flow sequence",
f"{tmp_path / 'test.yaml'}:5:3: note: included from here",
]
def test_secret_syntax_error_in_package(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "sub").mkdir()
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\npackages:\n p: !include sub/pkg.yaml\n"
),
"sub/pkg.yaml": "logger:\n level: !secret lvl\n",
"secrets.yaml": "lvl: [1, 2\nx: 1\n",
},
capsys,
)
assert lines == [
f"{tmp_path / 'secrets.yaml'}:2:2: error: expected ',' or ']', but got ':'",
f"{tmp_path / 'secrets.yaml'}:1:6: note: while parsing a flow sequence",
f"{tmp_path / 'test.yaml'}:5:3: note: included from here",
]
def test_undefined_secret(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
lines = _read(
tmp_path,
{
"test.yaml": "esphome:\n name: !secret nope\nhost:\n",
"secrets.yaml": "other: 1\n",
},
capsys,
)
assert lines == [f"{tmp_path / 'test.yaml'}:2:9: error: Secret 'nope' not defined"]
def test_missing_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
CORE.config_path = tmp_path / "missing.yaml"
CORE.error_format = ErrorFormat.LINE
assert read_config({}) is None
lines = capsys.readouterr().out.splitlines()
assert len(lines) == 1
assert lines[0].startswith(
f"{tmp_path / 'missing.yaml'}: error: Error reading file "
)
def test_source_trace_omitted(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\n"
"packages:\n p: !include ${ undefined_var }.yaml\n"
),
},
capsys,
)
# The "In: packages->p in test.yaml 5:3" trace would repeat the location
assert lines == [
(
f"{tmp_path / 'test.yaml'}:5:3: error: Error including file "
"'${ undefined_var }.yaml': Cannot load include with unresolved "
"substitutions: ${ undefined_var }.yaml."
),
]
def test_print_line_message_skips_blank_lines(
capsys: pytest.CaptureFixture[str],
) -> None:
_print_line_message("a.yaml:1:1", "error", "first\n\n second \n")
assert capsys.readouterr().out.splitlines() == [
"a.yaml:1:1: error: first",
"a.yaml:1:1: note: second",
]
def test_reset_restores_default_format() -> None:
CORE.error_format = ErrorFormat.LINE
CORE.reset()
assert CORE.error_format is ErrorFormat.YAML
@pytest.mark.parametrize(
"error",
[
KeyInvalid("Unable to find action", ["a", cv.ROOT_CONFIG_PATH, "b"]),
ExtraKeysInvalid(
"extra keys not allowed",
["a", cv.ROOT_CONFIG_PATH, "b"],
candidates=["c"],
),
],
)
def test_add_error_keeps_error_class(error: KeyInvalid) -> None:
res = Config()
res.add_error(error)
(added,) = res.errors
assert type(added) is type(error)
assert added.path == ["b"]
assert getattr(added, "candidates", None) == getattr(error, "candidates", None)
def test_default_format_logs_load_error(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
CORE.config_path = tmp_path / "missing.yaml"
assert read_config({}) is None
assert capsys.readouterr().out == ""
assert "Error while reading config: Invalid YAML syntax" in caplog.text
def test_exception_as_error_message(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# "ms" with no number makes the time period validator raise Invalid(ValueError)
lines = _read(
tmp_path,
{
"test.yaml": (
"esphome:\n name: test\nhost:\nsensor:\n - platform: template\n"
" name: foo\n update_interval: ms\n"
)
},
capsys,
)
assert lines == [
(
f"{tmp_path / 'test.yaml'}:7:22: error: "
"could not convert string to float: ''."
)
]
def _mark(line: int, column: int) -> yaml.Mark:
return yaml.Mark("a.yaml", 0, line, column, None, None)
@pytest.mark.parametrize(
("error", "expected"),
[
(
yaml.MarkedYAMLError(
problem="bad value", problem_mark=_mark(2, 1), note="a hint"
),
["a.yaml:3:2: error: bad value", "a.yaml:3:2: note: a hint"],
),
# No problem or context: the whole error text is used, note included
(
yaml.MarkedYAMLError(problem_mark=_mark(2, 1), note="a hint"),
[
'a.yaml:3:2: error: in "a.yaml", line 3, column 2',
"a.yaml:3:2: note: a hint",
],
),
],
)
def test_marked_yaml_error_note(
capsys: pytest.CaptureFixture[str],
error: yaml.MarkedYAMLError,
expected: list[str],
) -> None:
_print_marked_yaml_error(error)
assert capsys.readouterr().out.splitlines() == expected
def test_error_without_range_gives_config_path(
capsys: pytest.CaptureFixture[str],
) -> None:
CORE.config_path = Path("main.yaml")
res = Config()
res.add_error(vol.Invalid("bad value", ["sensor", 0, "name"]))
_print_line_errors(res)
assert capsys.readouterr().out.splitlines() == [
"main.yaml: error: bad value.",
"main.yaml: note: In: sensor->0->name",
]
def test_load_error_with_unprintable_cause(
capsys: pytest.CaptureFixture[str],
) -> None:
class Unprintable(Exception):
def __str__(self) -> str:
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
CORE.config_path = Path("main.yaml")
_print_line_load_error(InvalidYAMLError(Unprintable()))
assert capsys.readouterr().out.splitlines() == ["main.yaml: error: Unprintable()"]