diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 4db8f11432..c2f1378f9d 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, HexInt from esphome.cpp_generator import MockObj -from esphome.external_files import download_content_many +from esphome.external_files import download_web_files_in_config from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,23 +63,6 @@ def _compute_local_file_path(value: ConfigType) -> Path: return base_dir / key -def _download_all_web_files(config: list[ConfigType]) -> list[ConfigType]: - """Validate that all web-sourced files are cached, fetching missing/changed - ones in parallel before per-item validators read them off disk. - """ - items: list[tuple[str, Path]] = [] - for file_config in config: - conf_file = file_config.get(CONF_FILE, {}) - if conf_file.get(CONF_TYPE) != TYPE_WEB: - continue - url = conf_file[CONF_URL] - path = _compute_local_file_path(conf_file) - items.append((url, path)) - _LOGGER.debug("download_web_file: path=%s", path) - download_content_many(items) - return config - - def _file_schema(value: ConfigType | str) -> ConfigType: if isinstance(value, str): return _validate_file_shorthand(value) @@ -216,7 +199,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - _download_all_web_files, + lambda c: download_web_files_in_config(c, _compute_local_file_path), _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 7c4b366947..bbb51c03de 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.external_files import download_content_many +from esphome.external_files import download_web_files_in_config _LOGGER = logging.getLogger(__name__) @@ -92,23 +92,6 @@ def _compute_local_file_path(value: dict) -> Path: return base_dir / key -def _download_all_web_files(config): - """Validate that all web-sourced files are cached, fetching missing/changed - ones in parallel before per-item validators read them off disk. - """ - items: list[tuple[str, Path]] = [] - for file_config in config: - conf_file = file_config.get(CONF_FILE, {}) - if conf_file.get(CONF_TYPE) != TYPE_WEB: - continue - url = conf_file[CONF_URL] - path = _compute_local_file_path(conf_file) - items.append((url, path)) - _LOGGER.debug("download_web_file: path=%s", path) - download_content_many(items) - return config - - _PURPOSE_MAP = { "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], @@ -294,7 +277,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.All( cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - _download_all_web_files, + lambda c: download_web_files_in_config(c, _compute_local_file_path), ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) diff --git a/esphome/external_files.py b/esphome/external_files.py index d4f2649a10..7d27b05a56 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime import logging @@ -9,8 +9,9 @@ from pathlib import Path import requests import esphome.config_validation as cv -from esphome.const import __version__ +from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, TimePeriodSeconds +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -150,3 +151,28 @@ def download_content_many( with ThreadPoolExecutor(max_workers=workers) as ex: # list() forces iteration so exceptions surface here, not silently. list(ex.map(lambda item: download_content(item[0], item[1], timeout), items)) + + +# String constant rather than `from .const import TYPE_WEB` because each +# component defines its own `TYPE_WEB = "web"` literal in its module scope. +WEB_TYPE = "web" + + +def download_web_files_in_config( + config: list[ConfigType], + path_for: Callable[[ConfigType], Path], +) -> list[ConfigType]: + """Voluptuous-friendly validator that downloads any web-sourced files in + `config` in parallel. + + Each entry is expected to contain a `file` key whose value is a dict + that may be `{type: "web", url: ...}`; `path_for(file_dict)` returns + the cache path for that file. Returns `config` unchanged so it can be + slotted directly into a `cv.All(...)` chain. + """ + download_content_many( + (conf_file[CONF_URL], path_for(conf_file)) + for entry in config + if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE + ) + return config diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 182e424adb..22e7c02043 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -348,3 +348,46 @@ def test_download_content_many_propagates_errors( ] with pytest.raises(Invalid, match="could not download"): external_files.download_content_many(items) + + +@patch("esphome.external_files.download_content_many") +def test_download_web_files_in_config_filters_and_dispatches( + mock_many: MagicMock, setup_core: Path +) -> None: + """Only `file.type == "web"` entries should be forwarded to + download_content_many, and the unmodified config should be returned so + the helper can sit in a `cv.All(...)` chain. + """ + + def path_for(file_dict: dict) -> Path: + return setup_core / file_dict["url"].rsplit("/", 1)[-1] + + config = [ + {"file": {"type": "web", "url": "https://example.com/a"}}, + {"file": {"type": "local", "path": "/tmp/b"}}, + {"file": {"type": "web", "url": "https://example.com/c"}}, + {}, # no `file` key at all + ] + result = external_files.download_web_files_in_config(config, path_for) + + assert result is config + mock_many.assert_called_once() + items = mock_many.call_args[0][0] + assert items == [ + ("https://example.com/a", setup_core / "a"), + ("https://example.com/c", setup_core / "c"), + ] + + +@patch("esphome.external_files.download_content_many") +def test_download_web_files_in_config_no_web_entries( + mock_many: MagicMock, setup_core: Path +) -> None: + """A config with no web entries should still call through to + download_content_many (which is itself a no-op for empty input) so the + behavior stays consistent. + """ + config = [{"file": {"type": "local", "path": "/tmp/a"}}] + external_files.download_web_files_in_config(config, lambda _: setup_core / "x") + mock_many.assert_called_once() + assert mock_many.call_args[0][0] == []