diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a38fb2ed82c..1623117a367 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1999,38 +2000,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4092b4c0d5c..230a8e1f9ef 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3174,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv")