mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 16:30:22 +00:00
[espidf] Download tool archives in parallel during the install prefetch
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
import importlib.util
|
||||
import io
|
||||
@@ -899,12 +900,68 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
dist = get_idf_tools_path() / "dist"
|
||||
assert download.call_count == 2
|
||||
assert download.call_args_list[0][0] == (
|
||||
"https://example.com/cmake.tar.gz",
|
||||
dist / "cmake-3.30.2.tar.gz",
|
||||
# Archives download concurrently, so the call order is not fixed.
|
||||
calls = {call[0]: call[1] for call in download.call_args_list}
|
||||
assert set(calls) == {
|
||||
("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
|
||||
("https://example.com/ninja.zip", dist / "ninja.zip"),
|
||||
}
|
||||
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
|
||||
assert kwargs["sha256"] == "ab" * 32
|
||||
assert kwargs["size"] == 123
|
||||
# every archive reports into the one combined progress bar
|
||||
assert all(
|
||||
kw["progress"].__qualname__.startswith("BatchDownloadProgress.tracker")
|
||||
for kw in calls.values()
|
||||
)
|
||||
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
|
||||
|
||||
|
||||
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
|
||||
"""More than one archive fans out over a bounded thread pool."""
|
||||
entries = [
|
||||
{
|
||||
"name": f"tool{i}@1",
|
||||
"url": f"https://example.com/tool{i}.tar.gz",
|
||||
"size": 10,
|
||||
"sha256": "ab" * 32,
|
||||
"dest": f"tool{i}.tar.gz",
|
||||
}
|
||||
for i in range(6)
|
||||
]
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch(
|
||||
"esphome.espidf.framework.ThreadPoolExecutor", wraps=ThreadPoolExecutor
|
||||
) as pool,
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
pool.assert_called_once_with(max_workers=4)
|
||||
assert download.call_count == 6
|
||||
|
||||
|
||||
def test_prefetch_single_archive_uses_one_worker(tmp_path: Path) -> None:
|
||||
entries = json.loads(_PREFETCH_JSON)[:1]
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch(
|
||||
"esphome.espidf.framework.ThreadPoolExecutor", wraps=ThreadPoolExecutor
|
||||
) as pool,
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
pool.assert_called_once_with(max_workers=1)
|
||||
assert download.call_count == 1
|
||||
|
||||
|
||||
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
|
||||
@@ -964,6 +1021,11 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
) -> None:
|
||||
"""A single archive failing its download must not abort the prefetch of
|
||||
the remaining archives."""
|
||||
|
||||
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
|
||||
if "cmake" in url:
|
||||
raise OSError("network down")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
@@ -971,7 +1033,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
),
|
||||
patch(
|
||||
"esphome.espidf.framework.download_with_resume",
|
||||
side_effect=[OSError("network down"), None],
|
||||
side_effect=_fail_cmake_download,
|
||||
) as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
|
||||
@@ -21,6 +21,7 @@ import requests as req
|
||||
from esphome import framework_helpers
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
BatchDownloadProgress,
|
||||
_7z_extract_all,
|
||||
_detect_archive_root,
|
||||
_is_transient_download_error,
|
||||
@@ -1112,6 +1113,77 @@ class TestDownloadWithResume:
|
||||
assert mock_get.call_args[1]["headers"] == {}
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
|
||||
"""With a callback no bar is drawn; the callback sees the running
|
||||
byte count of this file, then its final verified size."""
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
resp = _mock_response(b"")
|
||||
resp.headers = {"content-length": "7"}
|
||||
resp.iter_content.return_value = [b"1234", b"567"]
|
||||
seen: list[int] = []
|
||||
with (
|
||||
patch("requests.get", return_value=resp),
|
||||
patch("esphome.framework_helpers.ProgressBar") as bar,
|
||||
):
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, size=7, progress=seen.append
|
||||
)
|
||||
assert seen == [0, 4, 7, 7]
|
||||
bar.assert_not_called()
|
||||
|
||||
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
|
||||
good = hashlib.sha256(b"12345678").hexdigest()
|
||||
seen: list[int] = []
|
||||
with patch("requests.get", return_value=_resumed_response(b"678")):
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
|
||||
)
|
||||
assert seen[0] == 5
|
||||
assert seen[-1] == 8
|
||||
|
||||
def test_progress_callback_credits_already_complete_download(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A verified dest from an earlier run still counts toward the batch."""
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
dest.write_bytes(b"12345678")
|
||||
seen: list[int] = []
|
||||
with patch("requests.get") as mock_get:
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, size=8, progress=seen.append
|
||||
)
|
||||
mock_get.assert_not_called()
|
||||
assert seen == [8]
|
||||
|
||||
|
||||
class TestBatchDownloadProgress:
|
||||
def test_sums_trackers_into_one_bar(self) -> None:
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
progress = BatchDownloadProgress("Downloading", 100)
|
||||
a = progress.tracker()
|
||||
b = progress.tracker()
|
||||
a(10)
|
||||
b(20)
|
||||
a(30)
|
||||
a(0) # a restart from zero takes that file's bytes back out
|
||||
bar_cls.assert_called_once_with("Downloading")
|
||||
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
|
||||
assert updates == [0.1, 0.3, 0.5, 0.2]
|
||||
|
||||
def test_clamps_at_one(self) -> None:
|
||||
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
progress = BatchDownloadProgress("Downloading", 10)
|
||||
progress.tracker()(25)
|
||||
assert bar_cls.return_value.update.call_args[0][0] == 1
|
||||
|
||||
def test_unknown_total_draws_nothing(self) -> None:
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
BatchDownloadProgress("Downloading", 0).tracker()(5)
|
||||
bar_cls.assert_not_called()
|
||||
|
||||
|
||||
class TestDownloadFromMirrors:
|
||||
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user