mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 02:28:42 +00:00
[core] Resolve file paths against the YAML file that declares them (#19259)
This commit is contained in:
@@ -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_"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user