From 457bb3ecc948a250a66497915154723342bb8e24 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch(