[core] Hash downloaded file paths at the default data dir location (#18824)

This commit is contained in:
J. Nick Koston
2026-08-28 21:08:15 -05:00
committed by GitHub
parent 06bc3d70c2
commit 3fea080ed8
4 changed files with 102 additions and 4 deletions
+7 -2
View File
@@ -783,7 +783,8 @@ class EsphomeCore:
can compare a locally computed hash against the one a device
advertises. Machine-local data is kept out of the input: build_path
(which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded,
and Path values are dumped relative to the config directory.
and Path values are dumped relative to the config directory, with
the data directory always at its default ``.esphome`` location.
"""
if self._config_hash is None:
from esphome import yaml_util
@@ -794,11 +795,15 @@ class EsphomeCore:
esphome_conf = dict(esphome_conf)
esphome_conf.pop(CONF_BUILD_PATH, None)
config[CONF_ESPHOME] = esphome_conf
relative_to = data_dir = None
if self.config_path is not None:
relative_to, data_dir = self.config_dir, self.data_dir
config_str = yaml_util.dump(
config,
show_secrets=True,
sort_keys=True,
relative_to=self.config_dir if self.config_path is not None else None,
relative_to=relative_to,
data_dir=data_dir,
)
self._config_hash = fnv1a_32bit_hash(config_str)
return self._config_hash
+20 -2
View File
@@ -1057,11 +1057,19 @@ def _load_yaml_internal_with_type(
loader.dispose()
def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None):
def dump(
dict_,
show_secrets=False,
sort_keys=False,
relative_to: Path | None = None,
data_dir: Path | None = None,
):
"""Dump YAML to a string and remove null.
When ``relative_to`` is given, Path values are dumped relative to that
directory (POSIX form) so the output is machine independent.
directory (POSIX form) so the output is machine independent; Path values
under ``data_dir`` are then dumped as ``.esphome/<rest>``. ``data_dir``
has no effect unless ``relative_to`` is also given.
"""
if show_secrets:
_SECRET_VALUES.clear()
@@ -1073,6 +1081,7 @@ def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None =
class _Dumper(ESPHomeDumper):
_redact_sensitive = not show_secrets
_relative_to = relative_to
_data_dir = data_dir
return yaml.dump(
dict_,
@@ -1231,6 +1240,9 @@ class ESPHomeDumper(yaml.SafeDumper):
# directory (in POSIX form) so the output does not depend on where the
# config lives on the machine that produced it.
_relative_to: Path | None = None
# Paths under this directory are dumped as ``.esphome/<rest>`` so the
# add-on's ``/data`` mount matches the CLI layout.
_data_dir: Path | None = None
def represent_mapping(self, tag, mapping, flow_style=None):
value = []
@@ -1274,6 +1286,12 @@ class ESPHomeDumper(yaml.SafeDumper):
# path that still cannot be relativized (e.g. a different drive)
# keeps its POSIX form so separators stay stable across OSes.
path = Path(os.path.normpath(value))
# Checked first: the default data dir sits inside the config dir.
if self._data_dir is not None and path.is_relative_to(
data_dir := os.path.normpath(self._data_dir)
):
rel = Path(".esphome") / path.relative_to(data_dir)
return self.represent_stringify(rel.as_posix())
with suppress(ValueError):
path = path.relative_to(
os.path.normpath(self._relative_to), walk_up=True
+28
View File
@@ -1127,6 +1127,34 @@ def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
assert hash1 == hash2
def test_config_hash_same_for_different_data_dirs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that downloaded file paths hash the same wherever data_dir lives."""
config_dir = tmp_path / "config"
config_dir.mkdir()
CORE.reset()
CORE.config_path = config_dir / "device.yaml"
CORE.config = {
"esphome": {"name": "test"},
"file": config_dir / ".esphome" / "image" / "c44630d6",
}
hash1 = CORE.config_hash
other_data_dir = tmp_path / "data"
CORE.reset()
monkeypatch.setenv("ESPHOME_DATA_DIR", str(other_data_dir))
CORE.config_path = config_dir / "device.yaml"
CORE.config = {
"esphome": {"name": "test"},
"file": other_data_dir / "image" / "c44630d6",
}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_make_app_name_cpp_no_mac_simple() -> None:
"""Test simple name without MAC suffix returns string literal."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
+47
View File
@@ -1706,6 +1706,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()