mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 09:46:03 +00:00
[core] Extract native toolchain package archives in parallel (#18840)
This commit is contained in:
@@ -92,16 +92,13 @@ def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None
|
||||
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
|
||||
patch.object(framework, "ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", ["http://f"]),
|
||||
patch.object(framework, "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", ["http://m"]),
|
||||
patch.object(framework, "install_package") as mock_install,
|
||||
patch.object(framework, "install_packages") as mock_install,
|
||||
patch.object(framework, "prefetch_packages") as mock_prefetch,
|
||||
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
|
||||
):
|
||||
framework.check_and_install(cv.Version(3, 1, 2))
|
||||
assert mock_prefetch.call_args.args[2] == {}
|
||||
assert [call.kwargs["resolve"] for call in mock_install.call_args_list] == [
|
||||
None,
|
||||
None,
|
||||
]
|
||||
assert mock_install.call_args.args[2] == {}
|
||||
|
||||
|
||||
def test_check_and_install_installed_toolchain_on_unsupported_host(
|
||||
@@ -142,7 +139,7 @@ def test_check_and_install_unsupported_host_without_toolchain_raises(
|
||||
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
|
||||
patch.object(framework, "install_package") as mock_install,
|
||||
patch.object(framework, "install_packages") as mock_install,
|
||||
patch.object(framework, "prefetch_packages") as mock_prefetch,
|
||||
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
|
||||
):
|
||||
@@ -150,54 +147,38 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None:
|
||||
assert paths.framework == tmp_path / "frameworks" / _recommended().tag
|
||||
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
|
||||
assert paths.ninja == tmp_path / "ninja"
|
||||
assert mock_install.call_count == 2
|
||||
# Full argument pinning: a copy-paste swap between the two near-identical
|
||||
# calls (mirrors, destination) must not stay green
|
||||
fw_call, tc_call = mock_install.call_args_list
|
||||
assert fw_call.args == (
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
_recommended().tag,
|
||||
tmp_path / "frameworks" / _recommended().tag,
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert fw_call.kwargs == {
|
||||
"expect": ("cores/esp8266", "tools/sdk", "libraries"),
|
||||
"resolve": _recommended().download,
|
||||
}
|
||||
assert tc_call.args == (
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert tc_call.kwargs == {
|
||||
"expect": ("bin", "xtensa-lx106-elf"),
|
||||
"resolve": framework.toolchain_download,
|
||||
}
|
||||
# The prefetch sees the same package specs as the installs
|
||||
assert mock_prefetch.call_args.args == (
|
||||
[
|
||||
# specs (mirrors, destination) must not stay green
|
||||
assert mock_install.call_args.args == (
|
||||
(
|
||||
(
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
_recommended().tag,
|
||||
tmp_path / "frameworks" / _recommended().tag,
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
],
|
||||
),
|
||||
tmp_path / "downloads",
|
||||
{
|
||||
framework.FRAMEWORK_PACKAGE: _recommended().download,
|
||||
framework.TOOLCHAIN_PACKAGE: framework.toolchain_download,
|
||||
},
|
||||
)
|
||||
# One spec list feeds both phases, so they cannot drift
|
||||
assert mock_prefetch.call_args.args == mock_install.call_args.args
|
||||
# PackageSpec instances, not bare tuples: the batch header reads .name
|
||||
assert all(
|
||||
isinstance(spec, framework.PackageSpec)
|
||||
for spec in mock_install.call_args.args[0]
|
||||
)
|
||||
|
||||
|
||||
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
|
||||
|
||||
@@ -523,6 +523,17 @@ class TestArchiveExtractAll:
|
||||
archive_extract_all(archive, dest)
|
||||
assert (dest / "file.txt").read_text() == "hi"
|
||||
|
||||
def test_progress_callback_passed_through(self, tmp_path: Path) -> None:
|
||||
"""The progress kwarg reaches the dispatched extractor."""
|
||||
archive = tmp_path / "test.tar.gz"
|
||||
archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"}))
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
fractions: list[float] = []
|
||||
archive_extract_all(archive, dest, progress=fractions.append)
|
||||
assert fractions[-1] == 1
|
||||
assert (dest / "file.txt").read_bytes() == b"hello"
|
||||
|
||||
def test_invalid_type_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="archive must be"):
|
||||
archive_extract_all(42, ".") # type: ignore[arg-type]
|
||||
@@ -1951,6 +1962,19 @@ class TestTarExtractAllBranches:
|
||||
mock_pb.assert_called_once_with("Extracting")
|
||||
mock_pb.return_value.update.assert_called()
|
||||
|
||||
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
|
||||
"""A progress callback wins over progress_header and ends at 1.0."""
|
||||
buf = _make_tar([_reg("a.txt"), _reg("b.txt")], {"a.txt": b"x", "b.txt": b"y"})
|
||||
fractions: list[float] = []
|
||||
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
|
||||
_tar_extract_all(
|
||||
buf, tmp_path, progress_header="Extracting", progress=fractions.append
|
||||
)
|
||||
mock_pb.assert_not_called()
|
||||
assert fractions == sorted(fractions)
|
||||
assert fractions[-1] == 1
|
||||
assert (tmp_path / "a.txt").is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _zip_extract_all — additional branch coverage
|
||||
@@ -1980,6 +2004,19 @@ class TestZipExtractAllBranches:
|
||||
mock_pb.assert_called_once_with("Unzipping")
|
||||
mock_pb.return_value.update.assert_called()
|
||||
|
||||
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
|
||||
"""A progress callback wins over progress_header and ends at 1.0."""
|
||||
buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")])
|
||||
fractions: list[float] = []
|
||||
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
|
||||
_zip_extract_all(
|
||||
buf, tmp_path, progress_header="Unzipping", progress=fractions.append
|
||||
)
|
||||
mock_pb.assert_not_called()
|
||||
assert fractions == sorted(fractions)
|
||||
assert fractions[-1] == 1
|
||||
assert (tmp_path / "a.txt").is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _rename_with_retry
|
||||
@@ -2137,6 +2174,20 @@ class TestSevenZipExtractAll:
|
||||
mock_pb.assert_called_once_with("Unpacking 7z")
|
||||
mock_pb.return_value.update.assert_called()
|
||||
|
||||
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
|
||||
"""A progress callback wins over progress_header; 7z reports 1.0 once."""
|
||||
buf = self._make_7z({"file.txt": b"x"})
|
||||
out = tmp_path / "out"
|
||||
out.mkdir()
|
||||
fractions: list[float] = []
|
||||
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
|
||||
_7z_extract_all(
|
||||
buf, out, progress_header="Unpacking 7z", progress=fractions.append
|
||||
)
|
||||
mock_pb.assert_not_called()
|
||||
assert fractions == [1]
|
||||
assert (out / "file.txt").is_file()
|
||||
|
||||
def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None:
|
||||
"""Names that resolve as absolute are silently skipped."""
|
||||
import py7zr
|
||||
@@ -2294,18 +2345,50 @@ def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_warn_prefetch_failures_names_each_failure(
|
||||
def test_warn_batch_failures_names_each_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The shared failure loop warns per job with the failure reason."""
|
||||
from esphome.framework_helpers import warn_prefetch_failures
|
||||
from esphome.framework_helpers import warn_batch_failures
|
||||
|
||||
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
|
||||
warn_batch_failures(
|
||||
[("toolchain-x@1", OSError("down"))], "Could not prefetch %s: %s"
|
||||
)
|
||||
assert "Could not prefetch toolchain-x@1: down" in caplog.text
|
||||
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
|
||||
warn_batch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
|
||||
assert "Prefetch of lib failed: gone" in caplog.text
|
||||
|
||||
|
||||
def test_extract_workers_caps_and_clamps() -> None:
|
||||
"""Extraction stops scaling well before high core counts, and a batch
|
||||
never asks for more workers than it has archives."""
|
||||
from esphome.framework_helpers import BATCH_EXTRACT_WORKERS, extract_workers
|
||||
|
||||
with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=64):
|
||||
assert extract_workers() == BATCH_EXTRACT_WORKERS
|
||||
assert extract_workers(2) == 2
|
||||
with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1):
|
||||
assert extract_workers(8) == 1
|
||||
|
||||
|
||||
def test_warn_batch_failures_unexpected_error_keeps_traceback(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unexpected error type is not reduced to a bare message; expected
|
||||
download failures stay message-only at WARNING."""
|
||||
from esphome.framework_helpers import warn_batch_failures
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
warn_batch_failures(
|
||||
[("pkg", TypeError("bad call")), ("lib", OSError("down"))],
|
||||
"Could not install %s: %s",
|
||||
)
|
||||
warnings = {r.getMessage(): r for r in caplog.records if r.levelname == "WARNING"}
|
||||
assert warnings["Could not install pkg: bad call"].exc_info is not None
|
||||
assert warnings["Could not install lib: down"].exc_info is None
|
||||
assert "Failure detail" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
|
||||
@@ -1727,7 +1727,7 @@ def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None:
|
||||
barrier.wait()
|
||||
|
||||
seed = _WaveManager(str(tmp_path))
|
||||
with patch.object(pf, "get_usable_cpu_count", return_value=2):
|
||||
with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=2):
|
||||
pf._preinstall(
|
||||
seed,
|
||||
[
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -45,7 +47,7 @@ def test_registry_download_resolves_once_per_process() -> None:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_registry_cache():
|
||||
def _fresh_registry_cache() -> Iterator[None]:
|
||||
# registry_download memoizes per process; tests reuse package names
|
||||
registry.registry_download.cache_clear()
|
||||
yield
|
||||
@@ -112,7 +114,7 @@ def _http_response(text: str) -> MagicMock:
|
||||
return resp
|
||||
|
||||
|
||||
def _registry_response(files: list[dict]):
|
||||
def _registry_response(files: list[dict]) -> AbstractContextManager[MagicMock]:
|
||||
"""Patch the consolidated HTTP path to serve a canned registry response."""
|
||||
payload = {"versions": [{"name": "1.0.0", "files": files}]}
|
||||
return patch.object(
|
||||
@@ -308,7 +310,11 @@ def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
|
||||
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
|
||||
assert mock_download.call_args[1] == {
|
||||
"sha256": "abc123",
|
||||
"size": 42,
|
||||
"progress": None,
|
||||
}
|
||||
|
||||
|
||||
def test_install_package_downloads_pinned(tmp_path: Path) -> None:
|
||||
@@ -334,7 +340,11 @@ def test_install_package_downloads_pinned(tmp_path: Path) -> None:
|
||||
)
|
||||
mock_registry.assert_not_called()
|
||||
assert mock_download.call_args[0][0] == "http://y/pinned.tar.gz"
|
||||
assert mock_download.call_args[1] == {"sha256": "def456", "size": 7}
|
||||
assert mock_download.call_args[1] == {
|
||||
"sha256": "def456",
|
||||
"size": 7,
|
||||
"progress": None,
|
||||
}
|
||||
|
||||
|
||||
def test_install_package_mirror_wins_over_pinned(tmp_path: Path) -> None:
|
||||
@@ -546,8 +556,10 @@ def test_registry_download_non_list_system_is_named() -> None:
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def _resolve_for(sizes: dict[str, int | None]):
|
||||
def resolve(name: str, version: str):
|
||||
def _resolve_for(
|
||||
sizes: dict[str, int | None],
|
||||
) -> Callable[[str, str], tuple[str, str, int | None]]:
|
||||
def resolve(name: str, version: str) -> tuple[str, str, int | None]:
|
||||
size = sizes[name]
|
||||
if size == -1:
|
||||
raise EsphomeError("registry down")
|
||||
@@ -567,8 +579,8 @@ def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -595,8 +607,8 @@ def test_prefetch_packages_uses_pinned_download(tmp_path: Path) -> None:
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ("payload",)),
|
||||
("b", "2.0", tmp_path / "b", [], ("payload",)),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
{"b": lambda: registry.Download("http://y/b.tar.gz", "def456", 20)},
|
||||
@@ -626,7 +638,7 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None:
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages([("a", "1.0", dest, [])], tmp_path / "dl")
|
||||
registry.prefetch_packages([("a", "1.0", dest, [], ())], tmp_path / "dl")
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
@@ -666,7 +678,7 @@ def test_prefetch_packages_waits_with_the_holders_progress(
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])],
|
||||
[("a", "1.0", dest, [], ()), ("b", "2.0", tmp_path / "b", [], ())],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert ticks == [0, 3, 10, 10]
|
||||
@@ -687,7 +699,10 @@ def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder(
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])],
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
@@ -713,8 +728,8 @@ def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None:
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -735,8 +750,8 @@ def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None:
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", marker_dest, []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", marker_dest, [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -758,9 +773,9 @@ def test_prefetch_packages_mirror_and_sizeless_stay_sequential(
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("c", "3.0", tmp_path / "c", []),
|
||||
("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
("c", "3.0", tmp_path / "c", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -781,8 +796,8 @@ def test_prefetch_packages_resolve_failure_defers_to_install(
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -803,8 +818,8 @@ def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None:
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
dl,
|
||||
)
|
||||
@@ -826,8 +841,8 @@ def test_prefetch_packages_download_failure_is_debug(
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
@@ -851,9 +866,225 @@ def test_prefetch_packages_unexpected_failure_warns(
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("a", "1.0", tmp_path / "a", [], ()),
|
||||
("b", "2.0", tmp_path / "b", [], ()),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert "TypeError" in caplog.text
|
||||
|
||||
|
||||
def _spec(
|
||||
name: str,
|
||||
version: str,
|
||||
dest: Path,
|
||||
mirrors: list[str] | None = None,
|
||||
expect: tuple[str, ...] = ("payload",),
|
||||
) -> registry.PackageSpec:
|
||||
return registry.PackageSpec(name, version, dest, mirrors or [], expect)
|
||||
|
||||
|
||||
def test_install_packages_extracts_verified_archives_in_parallel(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two prefetched archives install concurrently under one shared bar."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x" * 10)
|
||||
(dl / "b-2.0").write_bytes(b"y" * 20)
|
||||
with patch.object(registry, "install_package") as mock_install:
|
||||
registry.install_packages(
|
||||
[_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl
|
||||
)
|
||||
assert mock_install.call_count == 2
|
||||
calls = sorted(mock_install.call_args_list, key=lambda c: c[0][0])
|
||||
for c, (name, version) in zip(calls, [("a", "1.0"), ("b", "2.0")], strict=True):
|
||||
assert c[0][:3] == (name, version, tmp_path / name)
|
||||
assert c[1]["expect"] == ("payload",)
|
||||
assert callable(c[1]["extract_progress"])
|
||||
# Driving the tracker exercises the fraction-to-bytes scaling
|
||||
c[1]["extract_progress"](0.5)
|
||||
c[1]["extract_progress"](1.0)
|
||||
|
||||
|
||||
def test_install_packages_single_archive_stays_sequential(tmp_path: Path) -> None:
|
||||
"""One verified archive has nothing to parallelize; original order kept."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x")
|
||||
specs = [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")]
|
||||
with patch.object(registry, "install_package") as mock_install:
|
||||
registry.install_packages(specs, dl)
|
||||
assert [c[0][0] for c in mock_install.call_args_list] == ["a", "b"]
|
||||
for c in mock_install.call_args_list:
|
||||
assert "extract_progress" not in c[1]
|
||||
|
||||
|
||||
def test_batched_download_progress_announces_a_real_download_once(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A batched archive that fails verification streams again behind a bar
|
||||
that cannot move, so it says so once; a verified archive credits itself
|
||||
in one tick and stays quiet."""
|
||||
ticks: list[float] = []
|
||||
with caplog.at_level(logging.INFO):
|
||||
progress = registry._batched_download_progress("pkg", "1.0.0", ticks.append)
|
||||
progress(0)
|
||||
progress(4096)
|
||||
assert caplog.text.count("Re-downloading pkg 1.0.0") == 1
|
||||
# The shared bar never moves for a download; it tracks extraction
|
||||
assert ticks == [0.0, 0.0]
|
||||
|
||||
# A resumed .part starts mid-file, so the first tick is not zero
|
||||
caplog.clear()
|
||||
ticks.clear()
|
||||
with caplog.at_level(logging.INFO):
|
||||
resumed = registry._batched_download_progress("pkg", "1.0.0", ticks.append)
|
||||
resumed(8192)
|
||||
resumed(16384)
|
||||
assert caplog.text.count("Re-downloading pkg 1.0.0") == 1
|
||||
|
||||
caplog.clear()
|
||||
ticks.clear()
|
||||
with caplog.at_level(logging.INFO):
|
||||
verified = registry._batched_download_progress("pkg", "1.0.0", ticks.append)
|
||||
verified(42)
|
||||
assert "Re-downloading" not in caplog.text
|
||||
assert ticks == [0.0]
|
||||
|
||||
|
||||
def test_install_packages_no_batch_logs_no_header(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The batch header must not describe a batch that never ran."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
specs = [_spec("a", "1.0", tmp_path / "a")]
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch.object(registry, "install_package"),
|
||||
):
|
||||
registry.install_packages(specs, dl)
|
||||
assert "Extracting 0" not in caplog.text
|
||||
assert "package archive(s) with" not in caplog.text
|
||||
|
||||
|
||||
def test_install_packages_mirror_and_marker_stay_sequential(tmp_path: Path) -> None:
|
||||
"""Mirror overrides and marker hits never enter the parallel batch."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
for name, ver in (("a", "1.0"), ("b", "2.0"), ("c", "3.0"), ("d", "4.0")):
|
||||
(dl / f"{name}-{ver}").write_bytes(b"x")
|
||||
marked = tmp_path / "c"
|
||||
marked.mkdir()
|
||||
(marked / ".esphome_extracted").touch()
|
||||
specs = [
|
||||
_spec("a", "1.0", tmp_path / "a"),
|
||||
_spec("b", "2.0", tmp_path / "b", mirrors=["http://m"]),
|
||||
_spec("c", "3.0", marked),
|
||||
_spec("d", "4.0", tmp_path / "d"),
|
||||
]
|
||||
with patch.object(registry, "install_package") as mock_install:
|
||||
registry.install_packages(specs, dl)
|
||||
sequential = [
|
||||
c for c in mock_install.call_args_list if "extract_progress" not in c[1]
|
||||
]
|
||||
batched = [c for c in mock_install.call_args_list if "extract_progress" in c[1]]
|
||||
assert sorted(c[0][0] for c in sequential) == ["b", "c"]
|
||||
assert sorted(c[0][0] for c in batched) == ["a", "d"]
|
||||
|
||||
|
||||
def test_install_packages_first_failure_reraised(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Installs are mandatory: the first failure propagates, extras are logged."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x")
|
||||
(dl / "b-2.0").write_bytes(b"y")
|
||||
boom = EsphomeError("bad layout")
|
||||
|
||||
def _fail(name: str, *_a, **_kw) -> None:
|
||||
raise boom if name == "a" else EsphomeError("also bad")
|
||||
|
||||
with (
|
||||
patch.object(registry, "install_package", side_effect=_fail),
|
||||
pytest.raises(EsphomeError),
|
||||
):
|
||||
registry.install_packages(
|
||||
[_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl
|
||||
)
|
||||
# Every failure is named, including the re-raised one: its exception
|
||||
# message may not identify the package
|
||||
assert "Could not install a" in caplog.text
|
||||
assert "Could not install b" in caplog.text
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _batched_install(
|
||||
tmp_path: Path,
|
||||
extract_progress: Callable[[float], None] | None,
|
||||
prefill_archive: bool = True,
|
||||
) -> Iterator[tuple[MagicMock, MagicMock]]:
|
||||
"""Run a batched install_package of pkg@1.0.0; yields the download and
|
||||
extract mocks."""
|
||||
dest = tmp_path / "pkg"
|
||||
if prefill_archive:
|
||||
(tmp_path / "dl").mkdir()
|
||||
(tmp_path / "dl" / "pkg-1.0.0").write_bytes(b"x")
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
return_value=("http://x/pkg.tar.gz", "abc123", 42),
|
||||
),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg",
|
||||
"1.0.0",
|
||||
dest,
|
||||
[],
|
||||
tmp_path / "dl",
|
||||
expect=("payload",),
|
||||
extract_progress=extract_progress,
|
||||
)
|
||||
yield mock_download, mock_extract
|
||||
|
||||
|
||||
def test_install_package_extract_progress_suppresses_bars(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A batched install routes extraction fractions to the caller and keeps
|
||||
both private bars and per-package INFO lines off the shared bar."""
|
||||
fractions: list[float] = []
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
_batched_install(tmp_path, fractions.append) as (mock_download, mock_extract),
|
||||
):
|
||||
pass
|
||||
assert mock_extract.call_args[1]["progress"] == fractions.append
|
||||
# The download tracker reports zero bytes, keeping the shared bar honest
|
||||
download_progress = mock_download.call_args[1]["progress"]
|
||||
assert callable(download_progress)
|
||||
download_progress(42)
|
||||
assert fractions == [0.0]
|
||||
assert "Downloading pkg" not in caplog.text
|
||||
assert "Extracting pkg" not in caplog.text
|
||||
|
||||
|
||||
def test_install_package_batched_missing_archive_keeps_info_log(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A batched archive that unexpectedly needs a real download keeps the
|
||||
INFO line; the shared bar shows no progress for it."""
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
_batched_install(tmp_path, lambda _frac: None, prefill_archive=False),
|
||||
):
|
||||
pass
|
||||
assert "Downloading pkg 1.0.0" in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user