mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
Merge remote-tracking branch 'origin/dev' into store-yaml-firmware
# Conflicts: # esphome/components/api/api.proto # esphome/components/api/api_pb2.h # esphome/components/api/api_pb2_service.cpp # esphome/yaml_util.py # tests/integration/conftest.py # tests/unit_tests/test_yaml_util.py
This commit is contained in:
@@ -272,8 +272,54 @@ test: !include_dir_named test_dir
|
||||
assert ".hidden_dir" not in actual["test"]
|
||||
|
||||
|
||||
def test_include_dir_list(tmp_path: Path) -> None:
|
||||
"""!include_dir_list loads every .yaml file in the directory as a list."""
|
||||
test_dir = tmp_path / "test_dir"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "a.yaml").write_text("key: value_a")
|
||||
(test_dir / "b.yaml").write_text("key: value_b")
|
||||
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_list test_dir\n")
|
||||
|
||||
actual = yaml_util.load_yaml(test_yaml)
|
||||
|
||||
assert len(actual["test"]) == 2
|
||||
assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"}
|
||||
|
||||
|
||||
def test_include_dir_merge_list(tmp_path: Path) -> None:
|
||||
"""!include_dir_merge_list concatenates the lists from every .yaml file."""
|
||||
test_dir = tmp_path / "test_dir"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "a.yaml").write_text("- item_a1\n- item_a2\n")
|
||||
(test_dir / "b.yaml").write_text("- item_b1\n")
|
||||
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_merge_list test_dir\n")
|
||||
|
||||
actual = yaml_util.load_yaml(test_yaml)
|
||||
|
||||
assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"]
|
||||
|
||||
|
||||
def test_include_dir_merge_named(tmp_path: Path) -> None:
|
||||
"""!include_dir_merge_named merges the mappings from every .yaml file."""
|
||||
test_dir = tmp_path / "test_dir"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "a.yaml").write_text("key_a: value_a")
|
||||
(test_dir / "b.yaml").write_text("key_b: value_b")
|
||||
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("test: !include_dir_merge_named test_dir\n")
|
||||
|
||||
actual = yaml_util.load_yaml(test_yaml)
|
||||
|
||||
assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"}
|
||||
|
||||
|
||||
def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None:
|
||||
"""Test that _find_files works recursively through include_dir_named."""
|
||||
"""Test that find_files works recursively through include_dir_named."""
|
||||
# Copy fixture directory to temporary location
|
||||
src_dir = fixture_path / "yaml_util"
|
||||
dst_dir = tmp_path / "yaml_util"
|
||||
@@ -645,6 +691,31 @@ def test_include_file_has_unresolved_expressions(
|
||||
assert include.has_unresolved_expressions() == expected
|
||||
|
||||
|
||||
def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None:
|
||||
"""The mapping !include form rejects a non-string 'file' with a clear error."""
|
||||
entry = tmp_path / "entry.yaml"
|
||||
entry.write_text("wifi: !include\n file: [not, a, string]\n")
|
||||
with pytest.raises(EsphomeError, match="Include 'file' must be a string"):
|
||||
yaml_util.load_yaml(entry)
|
||||
|
||||
|
||||
def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None:
|
||||
"""A templated filename keeps its verbatim text (issue #18545)."""
|
||||
parent = tmp_path / "main.yaml"
|
||||
expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }'
|
||||
include = yaml_util.IncludeFile(parent, expr, None, lambda _: {})
|
||||
assert include.file == expr
|
||||
assert include.has_unresolved_expressions()
|
||||
assert repr(include) == f"IncludeFile({expr})"
|
||||
|
||||
|
||||
def test_represent_include_file_templated() -> None:
|
||||
"""Dumping a templated IncludeFile emits the raw expression unchanged."""
|
||||
expr = '${ "a/b.yaml" if flag else "../c.yaml" }'
|
||||
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {})
|
||||
assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n"
|
||||
|
||||
|
||||
def test_include_in_list_context() -> None:
|
||||
"""!include of a file returning a list is handled correctly,
|
||||
including when that list itself contains a nested IncludeFile."""
|
||||
@@ -993,8 +1064,10 @@ class _StubInclude:
|
||||
load_result: object = None,
|
||||
raise_on_load: EsphomeError | None = None,
|
||||
) -> None:
|
||||
self.file = Path(file)
|
||||
self.parent_file = parent_file or Path("/tmp/parent.yaml")
|
||||
# Default parent lives in a nonexistent directory so unresolved
|
||||
# stubs never glob real files during candidate expansion.
|
||||
self.file = file
|
||||
self.parent_file = parent_file or Path("/nonexistent/parent.yaml")
|
||||
self._unresolved = unresolved
|
||||
self._load_result = load_result if load_result is not None else {}
|
||||
self._raise = raise_on_load
|
||||
@@ -1205,6 +1278,247 @@ def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None:
|
||||
assert discovered.files.count(wifi_resolved) == 1
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_expands_directory_substitution(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A substitution spanning a directory segment globs across directories."""
|
||||
_write(tmp_path, "network/eth01/config.yaml", "ethernet:\n")
|
||||
_write(tmp_path, "network/eth02/config.yaml", "ethernet:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "network/${eth_model}/config.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "network/eth01/config.yaml").resolve() in resolved
|
||||
assert (tmp_path / "network/eth02/config.yaml").resolve() in resolved
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_loads_both_branches_of_issue_conditional(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Both branch files of the issue-17650 conditional load when present,
|
||||
including the filename with spaces."""
|
||||
_write(tmp_path, "empty.yaml", "{}\n")
|
||||
_write(tmp_path, "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml", "api:\n")
|
||||
_write(
|
||||
tmp_path,
|
||||
"boards/esp8266.yaml",
|
||||
"packages:\n"
|
||||
' - !include ${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"'
|
||||
' if enable_bluetooth_proxy else "../empty.yaml" }\n',
|
||||
)
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "boards/esp8266.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml").resolve() in (
|
||||
resolved
|
||||
)
|
||||
assert (tmp_path / "empty.yaml").resolve() in resolved
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_matches_bracket_filenames(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Glob metacharacters in the literal filename text stay literal."""
|
||||
_write(tmp_path, "sensor [a].yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "sensor [${x}].yaml")
|
||||
)
|
||||
assert "sensor [a].yaml" in {p.name for p in discovered.files}
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_ascending_glob(tmp_path: Path) -> None:
|
||||
"""A templated include reaching into a sibling directory via ``..`` globs."""
|
||||
_write(tmp_path, "shared/common.yaml", "api:\n")
|
||||
_write(tmp_path, "nodes/dev.yaml", "p: !include ../shared/${x}.yaml\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "nodes/dev.yaml")
|
||||
)
|
||||
assert (tmp_path / "shared/common.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_mapping_include_with_vars(tmp_path: Path) -> None:
|
||||
"""The mapping !include form (file + vars) expands a templated filename."""
|
||||
_write(tmp_path, "keys/a.yaml", "pin: ${num}\n")
|
||||
entry = _write(
|
||||
tmp_path,
|
||||
"entry.yaml",
|
||||
"wifi: !include\n file: keys/${n}.yaml\n vars:\n num: 4\n",
|
||||
)
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert (tmp_path / "keys/a.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_absolute_templated_include_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An absolute templated include is skipped gracefully instead of crashing."""
|
||||
shared = tmp_path / "shared"
|
||||
_write(tmp_path, "shared/common.yaml", "api:\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, f"{shared}/${{x}}.yaml")
|
||||
)
|
||||
assert (shared / "common.yaml").resolve() not in discovered.files
|
||||
assert any("Cannot glob include pattern" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_skips_dollar_named_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An on-disk filename containing ``$`` can't load; the glob skips it."""
|
||||
_write(tmp_path, "keys/a.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/b$roken.yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert "a.yaml" in names
|
||||
assert "b$roken.yaml" not in names
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_error_skips_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A filesystem error during candidate globbing warns and skips the include."""
|
||||
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
with (
|
||||
patch.object(Path, "glob", side_effect=OSError("boom")),
|
||||
caplog.at_level("DEBUG", logger="esphome.yaml_util"),
|
||||
):
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert [p.name for p in discovered.files] == ["entry.yaml"]
|
||||
matching = [
|
||||
r.levelname
|
||||
for r in caplog.records
|
||||
if "I/O error globbing include pattern" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_force_load_candidate_failure_warns_by_default(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A broken candidate logs at WARNING outside the discovery re-parse."""
|
||||
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
|
||||
entry = _write_entry_including(tmp_path, "keys/${n}.yaml")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
force_load_include_files(yaml_util.load_yaml(entry))
|
||||
matching = [
|
||||
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_glob_skips_hidden_files(tmp_path: Path) -> None:
|
||||
"""Candidate globs exclude hidden files, matching ``!include_dir_*``."""
|
||||
_write(tmp_path, "keys/device-a.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/.hidden.yaml", "api:\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${name}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert "device-a.yaml" in names
|
||||
assert ".hidden.yaml" not in names
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_bare_expression_not_expanded(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A fully dynamic filename never globs the whole directory."""
|
||||
_write(tmp_path, "sibling.yaml", "api:\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "${file}")
|
||||
)
|
||||
assert (tmp_path / "sibling.yaml").resolve() not in discovered.files
|
||||
assert any(
|
||||
"Cannot resolve !include" in r.message and r.levelname == "DEBUG"
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_self_glob_match_skipped(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A glob whose only match is the including file itself claims nothing."""
|
||||
entry = _write_entry_including(tmp_path, "${platform}.yaml")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert [p.name for p in discovered.files] == ["entry.yaml"]
|
||||
assert any("Cannot resolve !include" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_candidate_cycle_terminates(tmp_path: Path) -> None:
|
||||
"""Mutually glob-matching includes expand finitely and capture both files."""
|
||||
_write(tmp_path, "sub/a.yaml", "p: !include ${x}.yaml\n")
|
||||
_write(tmp_path, "sub/b.yaml", "p: !include ${y}.yaml\n")
|
||||
entry = _write(tmp_path, "entry.yaml", "wifi: !include sub/a.yaml\n")
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
names = {p.name for p in discovered.files}
|
||||
assert names == {"entry.yaml", "a.yaml", "b.yaml"}
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_many_candidates_keep_nested_includes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Every candidate's nested includes are discovered.
|
||||
|
||||
Regression test: the id()-based cycle guard is only safe while every
|
||||
traversed tree stays alive. Candidate trees used to be freed between
|
||||
loop iterations, so CPython recycled their addresses and later
|
||||
candidates' fresh trees were skipped as already seen, silently dropping
|
||||
their nested includes. Needs several candidates to manifest; two were
|
||||
not enough to trigger the reuse."""
|
||||
count = 12
|
||||
for i in range(count):
|
||||
_write(
|
||||
tmp_path, f"keys/k{i}.yaml", f"sensor{i}: !include ../nested/n{i}.yaml\n"
|
||||
)
|
||||
_write(tmp_path, f"nested/n{i}.yaml", f"api{i}: true\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${x}.yaml")
|
||||
)
|
||||
names = {p.name for p in discovered.files}
|
||||
expected = {f"n{i}.yaml" for i in range(count)}
|
||||
expected |= {f"k{i}.yaml" for i in range(count)}
|
||||
expected.add("entry.yaml")
|
||||
assert names == expected
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_bad_candidate_still_tracked(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A matched candidate that fails to parse warns even during discovery,
|
||||
stays tracked (the load listener fires before parsing), and doesn't block
|
||||
other candidates."""
|
||||
_write(tmp_path, "keys/good.yaml", "api:\n")
|
||||
_write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n")
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "keys/${name}.yaml")
|
||||
)
|
||||
resolved = set(discovered.files)
|
||||
assert (tmp_path / "keys/good.yaml").resolve() in resolved
|
||||
assert (tmp_path / "keys/bad.yaml").resolve() in resolved
|
||||
matching = [
|
||||
r.levelname for r in caplog.records if "Failed to load candidate" in r.message
|
||||
]
|
||||
assert matching == ["WARNING"]
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_tolerates_templated_top_level_include(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A literal include whose entire content is a templated ``!include`` is
|
||||
tracked and skipped instead of aborting discovery."""
|
||||
_write(tmp_path, "wrapper.yaml", "!include ${x}_settings.yaml\n")
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "wrapper.yaml")
|
||||
)
|
||||
assert (tmp_path / "wrapper.yaml").resolve() in discovered.files
|
||||
|
||||
|
||||
def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
|
||||
"""`track_yaml_loads` is the building block — sanity-check it resolves
|
||||
symlinks so callers can dedupe by identity."""
|
||||
@@ -1415,6 +1729,53 @@ def test_dump_path_dotdot_reference_outside_anchor() -> None:
|
||||
assert output.strip() == "file: ../shared/font.ttf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data_dir",
|
||||
[
|
||||
pytest.param(Path("/config/.esphome"), id="cli"),
|
||||
pytest.param(Path("/data"), id="addon"),
|
||||
],
|
||||
)
|
||||
def test_dump_path_under_data_dir_uses_default_location(data_dir: Path) -> None:
|
||||
"""Test that Path values under data_dir dump as .esphome/<rest> for any layout."""
|
||||
anchor = Path("/config").absolute()
|
||||
path = data_dir.absolute() / "image" / "c44630d6"
|
||||
output = yaml_util.dump(
|
||||
{"file": path}, relative_to=anchor, data_dir=data_dir.absolute()
|
||||
)
|
||||
assert output.strip() == "file: .esphome/image/c44630d6"
|
||||
|
||||
|
||||
def test_dump_path_equal_to_data_dir() -> None:
|
||||
"""Test that the data dir itself dumps as .esphome, matching the default layout."""
|
||||
anchor = Path("/config").absolute()
|
||||
data_dir = Path("/data").absolute()
|
||||
output = yaml_util.dump({"dir": data_dir}, relative_to=anchor, data_dir=data_dir)
|
||||
assert output.strip() == "dir: .esphome"
|
||||
default = yaml_util.dump(
|
||||
{"dir": anchor / ".esphome"}, relative_to=anchor, data_dir=anchor / ".esphome"
|
||||
)
|
||||
assert default == output
|
||||
|
||||
|
||||
def test_dump_path_outside_data_dir_still_relative_to_anchor() -> None:
|
||||
"""Test that data_dir does not affect paths that are not under it."""
|
||||
anchor = Path("/config").absolute()
|
||||
path = anchor / "fonts" / "arial.ttf"
|
||||
output = yaml_util.dump(
|
||||
{"file": path}, relative_to=anchor, data_dir=Path("/data").absolute()
|
||||
)
|
||||
assert output.strip() == "file: fonts/arial.ttf"
|
||||
|
||||
|
||||
def test_dump_path_data_dir_without_relative_to_is_unchanged() -> None:
|
||||
"""Test that data_dir alone does not change the output."""
|
||||
data_dir = Path("/data").absolute()
|
||||
path = data_dir / "image" / "c44630d6"
|
||||
output = yaml_util.dump({"file": path}, data_dir=data_dir)
|
||||
assert output.strip() == f"file: {path}"
|
||||
|
||||
|
||||
def test_dump_relative_to_does_not_leak_between_calls() -> None:
|
||||
"""Test that the relative_to flag is scoped to a single dump call."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
@@ -1586,3 +1947,122 @@ def test_scalar_include_path_equal_to_secret_is_swapped() -> None:
|
||||
with yaml_util.secret_values_registered({"hunter2": "the_secret"}):
|
||||
out = yaml_util.dump({"key": include})
|
||||
assert out == "key: !secret 'the_secret'\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# track_document_range=False (validated-config-cache fast path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAST_MODE_MAIN_YAML = """\
|
||||
defaults: &defaults
|
||||
port: 6053
|
||||
reboot_timeout: 15min
|
||||
|
||||
esphome:
|
||||
name: !secret devname
|
||||
|
||||
api:
|
||||
<<: *defaults
|
||||
port: 6054
|
||||
|
||||
number_value: 42
|
||||
float_value: 3.5
|
||||
lambda_value: !lambda 'return x * 2;'
|
||||
extend_value: !extend some_id
|
||||
remove_value: !remove some_id
|
||||
literal_value: !literal keep_me_verbatim
|
||||
included: !include included.yaml
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fast_mode_config_dir(tmp_path: Path) -> Path:
|
||||
_write(tmp_path, "main.yaml", FAST_MODE_MAIN_YAML)
|
||||
_write(tmp_path, "included.yaml", "inner_key: inner_value\ninner_num: 7\n")
|
||||
_write(tmp_path, "secrets.yaml", "devname: livingroom\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _resolve_includes(config: dict) -> dict:
|
||||
return {
|
||||
key: value.load() if isinstance(value, yaml_util.IncludeFile) else value
|
||||
for key, value in config.items()
|
||||
}
|
||||
|
||||
|
||||
def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None:
|
||||
"""Both modes produce equal values; only the metadata wrapping differs."""
|
||||
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||
|
||||
normal = _resolve_includes(yaml_util.load_yaml(yaml_file))
|
||||
fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False))
|
||||
|
||||
# Lambda has no __eq__; compare it by value and the rest structurally.
|
||||
fast_lambda = fast.pop("lambda_value")
|
||||
normal_lambda = normal.pop("lambda_value")
|
||||
assert fast == normal
|
||||
assert isinstance(fast_lambda, core.Lambda)
|
||||
assert fast_lambda.value == normal_lambda.value == "return x * 2;"
|
||||
assert fast["esphome"]["name"] == "livingroom"
|
||||
assert fast["api"]["port"] == 6054
|
||||
assert fast["api"]["reboot_timeout"] == "15min"
|
||||
assert fast["extend_value"] == Extend("some_id")
|
||||
assert fast["remove_value"] == Remove("some_id")
|
||||
# !literal wraps via make_literal, independent of range tracking.
|
||||
assert isinstance(fast["literal_value"], ESPLiteralValue)
|
||||
assert fast["literal_value"] == "keep_me_verbatim"
|
||||
|
||||
# Fast mode returns plain values; default mode keeps the range metadata.
|
||||
assert not isinstance(fast["number_value"], ESPHomeDataBase)
|
||||
assert not isinstance(fast["float_value"], ESPHomeDataBase)
|
||||
assert all(type(key) is str for key in fast)
|
||||
assert isinstance(normal["number_value"], ESPHomeDataBase)
|
||||
assert normal["number_value"].esp_range is not None
|
||||
assert all(isinstance(key, ESPHomeDataBase) for key in normal)
|
||||
|
||||
# Nested includes inherit fast mode through the recursive loader.
|
||||
included = fast["included"]
|
||||
assert not isinstance(included["inner_num"], ESPHomeDataBase)
|
||||
assert all(type(key) is str for key in included)
|
||||
|
||||
|
||||
def test_load_yaml_fast_mode_survives_pure_python_fallback(
|
||||
fast_mode_config_dir: Path,
|
||||
) -> None:
|
||||
"""The ESPHomePurePythonLoader retry must honour fast mode too."""
|
||||
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||
|
||||
class _AlwaysFailingLoader(yaml_util.ESPHomeLoader):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
raise EsphomeError("forced fallback to the pure-Python loader")
|
||||
|
||||
with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader):
|
||||
fast = yaml_util.load_yaml(yaml_file, track_document_range=False)
|
||||
|
||||
assert not isinstance(fast["number_value"], ESPHomeDataBase)
|
||||
assert all(type(key) is str for key in fast)
|
||||
|
||||
|
||||
def test_load_yaml_fast_mode_rejects_custom_loader() -> None:
|
||||
"""A caller-supplied yaml_loader cannot combine with fast mode."""
|
||||
with pytest.raises(ValueError, match="default yaml_loader"):
|
||||
yaml_util.parse_yaml(
|
||||
Path("x.yaml"),
|
||||
io.StringIO("a: 1"),
|
||||
lambda f: {},
|
||||
track_document_range=False,
|
||||
)
|
||||
|
||||
|
||||
def test_load_yaml_fast_mode_records_dropped_merge_keys(
|
||||
fast_mode_config_dir: Path,
|
||||
) -> None:
|
||||
"""The duplicate-merge-key bookkeeping must not crash on plain str keys.
|
||||
|
||||
With plain keys there is no esp_range, so the recorded location falls
|
||||
back to the parent file name.
|
||||
"""
|
||||
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||
|
||||
yaml_util.load_yaml(yaml_file, track_document_range=False)
|
||||
assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))]
|
||||
|
||||
Reference in New Issue
Block a user