[core] Resolve file paths against the YAML file that declares them (#19259)

This commit is contained in:
J. Nick Koston
2026-09-14 13:12:26 +12:00
committed by Jesse Hills
parent 977542061d
commit ebe72c3cef
2 changed files with 159 additions and 27 deletions
+40 -26
View File
@@ -15,6 +15,7 @@ from ipaddress import (
ip_network,
)
import logging
import os
from pathlib import Path
import re
from string import ascii_letters, digits
@@ -1967,38 +1968,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_"
+119 -1
View File
@@ -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():
@@ -3145,6 +3153,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("<unicode string>"), 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")