mirror of
https://github.com/esphome/esphome.git
synced 2026-10-13 09:42:50 +00:00
[esp32] Extract prefetched ESP-IDF tool archives in parallel (#18841)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
|
||||
"""Minimal idf_tools stand-in for the espidf helper-script tests."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
CURRENT_PLATFORM = "linux-amd64"
|
||||
TOOLS_FILE = "tools/tools.json"
|
||||
@@ -54,6 +55,7 @@ class _Tool:
|
||||
installed: Iterable[str] = (),
|
||||
broken: bool = False,
|
||||
) -> None:
|
||||
self.name = "" # filled in from the _TOOLS key below
|
||||
self.versions = versions
|
||||
self._recommended = recommended
|
||||
self.versions_installed = list(installed)
|
||||
@@ -69,6 +71,19 @@ class _Tool:
|
||||
if self._broken:
|
||||
raise ToolBinaryError("broken binary")
|
||||
|
||||
def get_path_for_version(self, version: str) -> str:
|
||||
return str(pathlib.Path(g.idf_tools_path) / "tools" / self.name / version)
|
||||
|
||||
def install(self, version: str) -> None:
|
||||
dest = pathlib.Path(self.get_path_for_version(version))
|
||||
dest.mkdir(exist_ok=True, parents=True)
|
||||
if self.name in os.environ.get("TEST_FAIL_INSTALL", "").split(","):
|
||||
# Fail mid-install like a torn unpack: the partial dir is left
|
||||
# behind and check_binary_valid's failure path exits the process
|
||||
(dest / ".partial").write_text("torn", encoding="utf-8")
|
||||
raise SystemExit(1)
|
||||
(dest / ".installed").write_text("ok", encoding="utf-8")
|
||||
|
||||
|
||||
_TOOLS = {
|
||||
"cmake": _Tool(
|
||||
@@ -97,8 +112,41 @@ _TOOLS = {
|
||||
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
|
||||
}
|
||||
|
||||
for _name, _tool in _TOOLS.items():
|
||||
_tool.name = _name
|
||||
|
||||
|
||||
def load_tools_info() -> dict[str, _Tool]:
|
||||
# Test hook: strip verification metadata from the named tools
|
||||
for name in os.environ.get("TEST_NO_SHA", "").split(","):
|
||||
if (tool := _TOOLS.get(name)) is not None:
|
||||
for version in tool.versions.values():
|
||||
if (download := version.get_download_for_platform("")) is not None:
|
||||
download.sha256 = ""
|
||||
if os.environ.get("TEST_BROKEN_COLLIDE"):
|
||||
# Test hook: a broken tool claims shared.tar.gz first; a later good
|
||||
# tool shares the basename under a different sha256
|
||||
first = _Tool(
|
||||
{"6.0": _Version(_Download("https://gh.test/shared.tar.gz", 66, "f1"))},
|
||||
"6.0",
|
||||
broken=True,
|
||||
)
|
||||
first.name = "broken-first-tool"
|
||||
shadowed = _Tool(
|
||||
{"7.0": _Version(_Download("https://other.test/shared.tar.gz", 77, "f2"))},
|
||||
"7.0",
|
||||
)
|
||||
shadowed.name = "shadowed-tool"
|
||||
return {**_TOOLS, "broken-first-tool": first, "shadowed-tool": shadowed}
|
||||
if os.environ.get("TEST_COLLIDE"):
|
||||
# Test hook: a second tool whose archive shares cmake's basename
|
||||
# but carries a different sha256
|
||||
collide = _Tool(
|
||||
{"5.0": _Version(_Download("https://other.test/cmake.tar.gz", 55, "ee"))},
|
||||
"5.0",
|
||||
)
|
||||
collide.name = "collide-tool"
|
||||
return {**_TOOLS, "collide-tool": collide}
|
||||
return _TOOLS
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from esphome.espidf.framework import (
|
||||
_patch_tools_json_demote_unused_tools,
|
||||
_patch_tools_json_for_linux_arm64,
|
||||
_prefetch_idf_tool_archives,
|
||||
_preinstall_idf_tool_archives,
|
||||
_read_stamp,
|
||||
_stamp_covers,
|
||||
_windows_long_paths_enabled,
|
||||
@@ -403,6 +404,7 @@ def espidf_mocks(setup_core: Path):
|
||||
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
|
||||
patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"),
|
||||
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
|
||||
patch("esphome.espidf.framework._preinstall_idf_tool_archives"),
|
||||
patch("esphome.espidf.framework._write_stamp"),
|
||||
patch("esphome.espidf.framework._check_stamp", return_value=True),
|
||||
patch("esphome.espidf.framework._stamp_covers", return_value=True),
|
||||
@@ -965,7 +967,7 @@ def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
|
||||
patch("esphome.framework_helpers.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
assert not _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
@@ -1001,7 +1003,7 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
|
||||
):
|
||||
# Materialize the lazy mock before threads race its first creation
|
||||
tracker = progress_cls.return_value.tracker.return_value
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
assert _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
dist = get_idf_tools_path() / "dist"
|
||||
# Archives download concurrently, so the call order is not fixed.
|
||||
@@ -1051,11 +1053,16 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
|
||||
assert download.call_count == 6
|
||||
|
||||
|
||||
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
|
||||
def test_prefetch_reverifies_already_downloaded_archives(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A pre-existing archive is not skipped: download_with_resume keeps it
|
||||
only when the sha256 matches, so the pre-extraction can trust it."""
|
||||
dist = get_idf_tools_path() / "dist"
|
||||
dist.mkdir(parents=True)
|
||||
(dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached")
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
@@ -1065,9 +1072,12 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
# only the missing archive is downloaded
|
||||
assert download.call_count == 1
|
||||
assert download.call_args[0][1] == dist / "ninja.zip"
|
||||
assert sorted(call[0][1] for call in download.call_args_list) == [
|
||||
dist / "cmake-3.30.2.tar.gz",
|
||||
dist / "ninja.zip",
|
||||
]
|
||||
# The log distinguishes verifying cached archives from real downloads
|
||||
assert "Downloading 2 ESP-IDF tool archive(s) (1 cached, verifying)" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1098,9 +1108,12 @@ def test_prefetch_failures_never_raise(
|
||||
),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
ran = _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
assert expected_log in caplog.text
|
||||
# Nothing verified the archives end to end, so the pre-extraction must
|
||||
# not trust dist/
|
||||
assert not ran
|
||||
|
||||
|
||||
def test_prefetch_total_failure_logs_error(
|
||||
@@ -1124,6 +1137,31 @@ def test_prefetch_total_failure_logs_error(
|
||||
assert "Every ESP-IDF tool prefetch failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_partial_failure_does_not_claim_dist_is_verified(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A failed entry may leave a cached archive nobody could re-hash or
|
||||
remove behind; the pre-extraction would extract it unchecked."""
|
||||
|
||||
def _fail_ninja(url: str, *args, **kwargs) -> None:
|
||||
if "ninja" in url:
|
||||
raise OSError("still there, unverified")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch(
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=_fail_ninja,
|
||||
),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress"),
|
||||
):
|
||||
assert not _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
|
||||
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -1188,22 +1226,33 @@ def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
|
||||
cmd = run.call_args[0][0]
|
||||
assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"]
|
||||
assert cmd[1].endswith("get_tool_downloads.py")
|
||||
# the script inherits the caller's env plus the framework tools PYTHONPATH
|
||||
# the script inherits the caller's env plus an explicit PYTHONPATH:
|
||||
# sibling scripts, the esphome package root, the framework's idf_tools
|
||||
env = run.call_args[1]["env"]
|
||||
assert env["IDF_TOOLS_PATH"] == "/x"
|
||||
assert env["PYTHONPATH"] == str(tmp_path / "tools")
|
||||
assert env["PYTHONPATH"] == os.pathsep.join(
|
||||
(
|
||||
str(_ESPIDF_SCRIPTS_DIR),
|
||||
str(_ESPIDF_SCRIPTS_DIR.parents[1]),
|
||||
str(tmp_path / "tools"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_framework_install_prefetches_before_installer(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The prefetch runs before idf_tools.py install so the installer finds
|
||||
the archives already in dist/."""
|
||||
"""The prefetch downloads and the pre-extraction both run before
|
||||
idf_tools.py install so the installer finds the tools in place."""
|
||||
calls: list[str] = []
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework._prefetch_idf_tool_archives",
|
||||
side_effect=lambda *a, **k: calls.append("prefetch"),
|
||||
side_effect=lambda *a, **k: calls.append("prefetch") or True,
|
||||
),
|
||||
patch(
|
||||
"esphome.espidf.framework._preinstall_idf_tool_archives",
|
||||
side_effect=lambda *a, **k: calls.append("preinstall"),
|
||||
),
|
||||
):
|
||||
espidf_mocks.run_ok.side_effect = lambda *a, **k: (
|
||||
@@ -1211,7 +1260,24 @@ def test_framework_install_prefetches_before_installer(
|
||||
)
|
||||
check_esp_idf_install(_IDF_VERSION, force=True)
|
||||
|
||||
assert calls.index("prefetch") < calls.index("install")
|
||||
assert calls.index("prefetch") < calls.index("preinstall") < calls.index("install")
|
||||
|
||||
|
||||
def test_framework_install_skips_preextraction_when_prefetch_did_not_run(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Without a prefetch nothing verified the archives in dist/, so the
|
||||
installer alone decides what to trust."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework._prefetch_idf_tool_archives",
|
||||
return_value=False,
|
||||
),
|
||||
patch("esphome.espidf.framework._preinstall_idf_tool_archives") as preinstall,
|
||||
):
|
||||
check_esp_idf_install(_IDF_VERSION, force=True)
|
||||
|
||||
preinstall.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1220,13 +1286,16 @@ def test_framework_install_prefetches_before_installer(
|
||||
|
||||
|
||||
_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub"
|
||||
_ESPIDF_SCRIPTS_DIR = Path(__file__).parents[2] / "esphome" / "espidf"
|
||||
|
||||
|
||||
def _run_downloads_script(
|
||||
tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None
|
||||
def _run_espidf_script(
|
||||
tmp_path: Path,
|
||||
script_name: str,
|
||||
*args: str,
|
||||
env_extra: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the real get_tool_downloads.py against the stub idf_tools module."""
|
||||
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
|
||||
"""Run a real espidf helper script against the stub idf_tools module."""
|
||||
env = os.environ | {
|
||||
"PYTHONPATH": str(_IDF_TOOLS_STUB_DIR),
|
||||
"IDF_TOOLS_PATH": str(tmp_path / "tp"),
|
||||
@@ -1234,7 +1303,12 @@ def _run_downloads_script(
|
||||
if env_extra:
|
||||
env |= env_extra
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), str(tmp_path / "fw"), *args],
|
||||
[
|
||||
sys.executable,
|
||||
str(_ESPIDF_SCRIPTS_DIR / script_name),
|
||||
str(tmp_path / "fw"),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
@@ -1246,7 +1320,7 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
|
||||
"""Installed versions are skipped, tools that fail their binary check are
|
||||
still listed, rename_dist decides the dist filename, and idf_tools' stdout
|
||||
chatter stays off the JSON channel."""
|
||||
result = _run_downloads_script(tmp_path, "esp32", "required")
|
||||
result = _run_espidf_script(tmp_path, "get_tool_downloads.py", "esp32", "required")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
downloads = {d["name"]: d for d in json.loads(result.stdout)}
|
||||
@@ -1262,8 +1336,9 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
|
||||
result = _run_downloads_script(
|
||||
result = _run_espidf_script(
|
||||
tmp_path,
|
||||
"get_tool_downloads.py",
|
||||
"esp32",
|
||||
"required",
|
||||
env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"},
|
||||
@@ -1274,13 +1349,13 @@ def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
|
||||
assert all(d["url"].startswith("https://mirror.test/") for d in downloads)
|
||||
|
||||
|
||||
def _run_downloads_inprocess(
|
||||
def _run_espidf_script_inprocess(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
script_name: str,
|
||||
*args: str,
|
||||
) -> list[dict]:
|
||||
"""Execute get_tool_downloads.py in-process against the stub idf_tools.
|
||||
) -> None:
|
||||
"""Execute an espidf helper script in-process against the stub idf_tools.
|
||||
|
||||
Unlike the subprocess variant this runs under coverage, exercising the
|
||||
script's own lines.
|
||||
@@ -1291,10 +1366,24 @@ def _run_downloads_inprocess(
|
||||
stub = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(stub)
|
||||
monkeypatch.setitem(sys.modules, "idf_tools", stub)
|
||||
# _tool_resolution binds idf_tools objects at import; force a fresh
|
||||
# import against this test's stub instance
|
||||
monkeypatch.delitem(sys.modules, "_tool_resolution", raising=False)
|
||||
# python <script> puts the script's directory on sys.path; runpy does not
|
||||
monkeypatch.syspath_prepend(str(_ESPIDF_SCRIPTS_DIR))
|
||||
monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp"))
|
||||
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
|
||||
script = _ESPIDF_SCRIPTS_DIR / script_name
|
||||
monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args])
|
||||
runpy.run_path(str(script))
|
||||
|
||||
|
||||
def _run_downloads_inprocess(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
*args: str,
|
||||
) -> list[dict]:
|
||||
_run_espidf_script_inprocess(tmp_path, monkeypatch, "get_tool_downloads.py", *args)
|
||||
return json.loads(capsys.readouterr().out)
|
||||
|
||||
|
||||
@@ -2068,3 +2157,298 @@ def test_check_windows_path_length_long_path_warns(
|
||||
assert "long path support" in message
|
||||
# The install is global now; the remedy is the prefix env, not moving the project.
|
||||
assert "ESPHOME_ESP_IDF_PREFIX" in message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _preinstall_idf_tool_archives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_preinstall_streams_script_with_workers(tmp_path: Path) -> None:
|
||||
"""The pre-extraction streams install_tool_archives.py with the worker
|
||||
count and the same targets/tools the installer will get."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework._run_idf_tools_script",
|
||||
return_value=(True, None, None),
|
||||
) as run_script,
|
||||
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=3),
|
||||
):
|
||||
_preinstall_idf_tool_archives(
|
||||
tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "x"}
|
||||
)
|
||||
run_script.assert_called_once_with(
|
||||
tmp_path,
|
||||
"install_tool_archives.py",
|
||||
"ESP-IDF tool archive extraction",
|
||||
args=["esp32,esp32c3", "3", "required", "cmake"],
|
||||
env={"IDF_TOOLS_PATH": "x"},
|
||||
stream_output=True,
|
||||
)
|
||||
|
||||
|
||||
def test_preinstall_caps_workers(tmp_path: Path) -> None:
|
||||
"""A high core count is capped; the workers share one disk."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework._run_idf_tools_script",
|
||||
return_value=(True, None, None),
|
||||
) as run_script,
|
||||
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=64),
|
||||
):
|
||||
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
from esphome.framework_helpers import BATCH_EXTRACT_WORKERS
|
||||
|
||||
assert run_script.call_args.kwargs["args"][1] == str(BATCH_EXTRACT_WORKERS)
|
||||
|
||||
|
||||
def test_preinstall_script_failure_only_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed pre-extraction leaves the install to the sequential path."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework._run_idf_tools_script",
|
||||
return_value=(False, None, None),
|
||||
),
|
||||
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1),
|
||||
):
|
||||
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
assert "pre-extraction failed" in caplog.text
|
||||
|
||||
|
||||
def test_preinstall_exception_only_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unexpected error must not become a new way for the install to
|
||||
fail, and keeps its traceback at DEBUG like the prefetch sibling."""
|
||||
with (
|
||||
caplog.at_level(logging.DEBUG),
|
||||
patch(
|
||||
"esphome.espidf.framework._run_idf_tools_script",
|
||||
side_effect=TypeError("bad call"),
|
||||
),
|
||||
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1),
|
||||
):
|
||||
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
assert any("pre-extraction failed" in r.message for r in caplog.records)
|
||||
detail = next(
|
||||
r for r in caplog.records if r.message == "Pre-extraction failure detail"
|
||||
)
|
||||
assert detail.exc_info is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install_tool_archives.py (against the stub idf_tools module in fixtures/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_dist(tmp_path: Path, *names: str) -> None:
|
||||
dist = tmp_path / "tp" / "dist"
|
||||
dist.mkdir(parents=True, exist_ok=True)
|
||||
for name in names:
|
||||
(dist / name).write_bytes(b"x")
|
||||
|
||||
|
||||
def test_install_tool_archives_extracts_pending_in_parallel(tmp_path: Path) -> None:
|
||||
"""Subprocess end-to-end: tools with a prefetched archive install
|
||||
concurrently; installed and broken tools stay with the installer."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
|
||||
result = _run_espidf_script(
|
||||
tmp_path, "install_tool_archives.py", "esp32", "8", "required"
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
|
||||
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
|
||||
assert not (tools / "installed-tool").exists()
|
||||
assert not (tools / "broken-tool").exists()
|
||||
# The worker count clamps to the pending count
|
||||
assert (
|
||||
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
|
||||
"cmake@3.30.2, ninja@1.12.1" in result.stdout
|
||||
)
|
||||
assert "leaving broken broken-tool to the installer" in result.stderr
|
||||
|
||||
|
||||
def test_install_tool_archives_skips_colliding_dest(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A second tool sharing a dist filename with a different sha256 was
|
||||
never verified by the prefetch; it stays with the installer."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
|
||||
monkeypatch.setenv("TEST_COLLIDE", "1")
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "8", "required"
|
||||
)
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
|
||||
assert not (tools / "collide-tool").exists()
|
||||
assert (
|
||||
"leaving collide-tool@5.0 to the installer: cmake.tar.gz "
|
||||
"holds a different archive" in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_install_tool_archives_broken_tool_still_claims_its_dest(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""The prefetch verified the broken tool's archive, so a later tool
|
||||
sharing its filename under another sha256 must not extract from it."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "shared.tar.gz")
|
||||
monkeypatch.setenv("TEST_BROKEN_COLLIDE", "1")
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "8", "required"
|
||||
)
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert not (tools / "broken-first-tool").exists()
|
||||
assert not (tools / "shadowed-tool").exists()
|
||||
assert (
|
||||
"leaving shadowed-tool@7.0 to the installer: shared.tar.gz "
|
||||
"holds a different archive" in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_install_tool_archives_single_pending_stays_sequential(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""One pending archive has nothing to parallelize; the installer keeps
|
||||
its normal output."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz")
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
|
||||
)
|
||||
assert not (tmp_path / "tp" / "tools").exists()
|
||||
out = capsys.readouterr().out
|
||||
assert "Extracting" not in out
|
||||
# A resolution drift that empties pending stays observable
|
||||
assert "1 prefetched tool archive(s)" in out
|
||||
|
||||
|
||||
def test_install_tool_archives_failed_install_left_to_installer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A per-tool failure removes the torn dest dir so the installer cannot
|
||||
trust it, and the other tools still install; the nonzero exit lets the
|
||||
caller point at the streamed detail."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
|
||||
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
|
||||
)
|
||||
assert excinfo.value.code == 1
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
|
||||
assert not (tools / "ninja" / "1.12.1").exists()
|
||||
err = capsys.readouterr().err
|
||||
assert "pre-extracting ninja@1.12.1 failed" in err
|
||||
assert "1 of 2 pre-extractions failed" in err
|
||||
|
||||
|
||||
def test_install_tool_archives_all_failed_exits_nonzero(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Every job failing is a systematic fault; the nonzero exit lets the
|
||||
caller log it."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
|
||||
monkeypatch.setenv("TEST_FAIL_INSTALL", "cmake,ninja")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
|
||||
)
|
||||
assert excinfo.value.code == 1
|
||||
assert "2 of 2 pre-extractions failed" in capsys.readouterr().err
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert not (tools / "cmake" / "3.30.2").exists()
|
||||
assert not (tools / "ninja" / "1.12.1").exists()
|
||||
|
||||
|
||||
def test_install_tool_archives_inprocess_dedupes_and_skips(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""In-process full flow: duplicate tool@version specs collapse to one
|
||||
job, broken and installed tools are skipped, both pending tools install."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
"install_tool_archives.py",
|
||||
"esp32",
|
||||
"8",
|
||||
"cmake",
|
||||
"ninja",
|
||||
"cmake@3.30.2",
|
||||
"installed-tool",
|
||||
"broken-tool",
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert (
|
||||
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
|
||||
"cmake@3.30.2, ninja@1.12.1" in captured.out
|
||||
)
|
||||
assert "extracted cmake@3.30.2" in captured.out
|
||||
assert "extracted ninja@1.12.1" in captured.out
|
||||
assert "leaving broken broken-tool to the installer" in captured.err
|
||||
tools = tmp_path / "tp" / "tools"
|
||||
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
|
||||
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
|
||||
assert not (tools / "installed-tool").exists()
|
||||
assert not (tools / "broken-tool").exists()
|
||||
|
||||
|
||||
def test_install_tool_archives_surviving_torn_dir_escalates(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A torn dir that survives cleanup could fool the installer; the exit
|
||||
is nonzero even though the other tool succeeded."""
|
||||
import esphome.helpers
|
||||
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
|
||||
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
|
||||
monkeypatch.setattr(
|
||||
esphome.helpers, "rmtree", MagicMock(side_effect=OSError("busy"))
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
|
||||
)
|
||||
assert excinfo.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "could not remove" in err
|
||||
assert "1 of 2 pre-extractions failed" in err
|
||||
# The surviving dir is named again after the summary, where the caller's
|
||||
# warning points
|
||||
assert err.rstrip().endswith("delete it manually if the build fails")
|
||||
assert "partial tool dir survived" in err
|
||||
assert (tmp_path / "tp" / "tools" / "cmake" / "3.30.2" / ".installed").is_file()
|
||||
|
||||
|
||||
def test_install_tool_archives_skips_unverifiable_archives(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""An entry the prefetch could not verify is never extracted, even with
|
||||
an archive on disk."""
|
||||
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
|
||||
monkeypatch.setenv("TEST_NO_SHA", "cmake,ninja")
|
||||
_run_espidf_script_inprocess(
|
||||
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
|
||||
)
|
||||
assert not (tmp_path / "tp" / "tools").exists()
|
||||
assert "0 prefetched tool archive(s)" in (capsys.readouterr().out)
|
||||
|
||||
Reference in New Issue
Block a user