mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 10:06:03 +00:00
Hoist duplicated _download_all_web_files into external_files.download_web_files_in_config
audio_file and speaker.media_player had byte-identical helpers that collected web URLs from a list-of-file-configs and handed them to download_content_many. Move that logic into external_files as `download_web_files_in_config(config, path_for)` -- callers only have to pass the per-component cache-path callback now.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] == []
|
||||
|
||||
Reference in New Issue
Block a user