mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[core] Prefetch PlatformIO packages in parallel (#18769)
This commit is contained in:
@@ -911,7 +911,7 @@ def test_prefetch_leaves_unverifiable_entries_to_the_installer(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.framework_helpers.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
):
|
||||
@@ -934,7 +934,7 @@ def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
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)
|
||||
@@ -952,7 +952,7 @@ def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.framework_helpers.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress"),
|
||||
):
|
||||
@@ -967,7 +967,7 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.framework_helpers.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
):
|
||||
@@ -1011,7 +1011,7 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.framework_helpers.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch(
|
||||
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
|
||||
@@ -1032,7 +1032,7 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
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)
|
||||
@@ -1065,7 +1065,7 @@ def test_prefetch_failures_never_raise(
|
||||
with (
|
||||
patch("esphome.espidf.framework.run_command", return_value=run_result),
|
||||
patch(
|
||||
"esphome.espidf.framework.download_with_resume",
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=download_error,
|
||||
),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
@@ -1087,7 +1087,7 @@ def test_prefetch_total_failure_logs_error(
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch(
|
||||
"esphome.espidf.framework.download_with_resume",
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=OSError("proxy refuses everything"),
|
||||
),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
@@ -1112,7 +1112,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch(
|
||||
"esphome.espidf.framework.download_with_resume",
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=_fail_cmake_download,
|
||||
) as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
@@ -1133,7 +1133,7 @@ def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> Non
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume"),
|
||||
patch("esphome.framework_helpers.download_with_resume"),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
|
||||
|
||||
@@ -2280,6 +2280,32 @@ class TestGetProjectCxxCompileFlags:
|
||||
assert get_project_cxx_compile_flags() == []
|
||||
|
||||
|
||||
def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
|
||||
"""The batch runner passes the tracker positionally; the shared adapter
|
||||
must deliver it as download_with_resume's progress keyword."""
|
||||
from esphome.framework_helpers import resume_fetch_job
|
||||
|
||||
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
|
||||
fetch = resume_fetch_job("https://x/a.zip", tmp_path / "a", sha256="ff", size=9)
|
||||
tracker = lambda done: None # noqa: E731
|
||||
fetch(tracker)
|
||||
mock_download.assert_called_once_with(
|
||||
"https://x/a.zip", tmp_path / "a", progress=tracker, sha256="ff", size=9
|
||||
)
|
||||
|
||||
|
||||
def test_warn_prefetch_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
|
||||
|
||||
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
|
||||
assert "Could not prefetch toolchain-x@1: down" in caplog.text
|
||||
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
|
||||
assert "Prefetch of lib failed: gone" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
@@ -2312,3 +2338,18 @@ def test_strip_win_long_path_prefix(
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.framework_helpers.sys.platform", platform):
|
||||
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
|
||||
def test_discard_partial_download_logs_undeletable(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unremovable staging file leaves a debug trace; the caller's
|
||||
cache is never pruned, so silence would hide unbounded growth."""
|
||||
dest = tmp_path / "archive"
|
||||
dest.write_bytes(b"stale")
|
||||
with (
|
||||
patch.object(Path, "unlink", side_effect=OSError("busy")),
|
||||
caplog.at_level(logging.DEBUG),
|
||||
):
|
||||
framework_helpers.discard_partial_download(dest)
|
||||
assert "Could not remove" in caplog.text
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
"""Tests for the parallel PlatformIO package prefetch."""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from filelock import Timeout
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
import esphome.platformio.prefetch as pf
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _core(tmp_path: Path):
|
||||
CORE.reset()
|
||||
CORE.build_path = str(tmp_path)
|
||||
CORE.name = "testenv"
|
||||
pio_loggers = ("Tool Manager", "Library Manager", "Platform Manager")
|
||||
saved_propagate = {n: pf.logging.getLogger(n).propagate for n in pio_loggers}
|
||||
saved_filters = {n: list(pf.logging.getLogger(n).filters) for n in pio_loggers}
|
||||
# The real setup_log would swap pytest's root-handler formatter
|
||||
with patch("esphome.log.setup_log"):
|
||||
yield
|
||||
# main() flips these process-wide; keep the suite hermetic
|
||||
for n, flag in saved_propagate.items():
|
||||
pf.logging.getLogger(n).propagate = flag
|
||||
pf.logging.getLogger(n).filters[:] = saved_filters[n]
|
||||
CORE.reset()
|
||||
|
||||
|
||||
class _FakeSpec(SimpleNamespace):
|
||||
"""PackageSpec stand-in for the attributes the prefetch reads."""
|
||||
|
||||
def __init__(
|
||||
self, *, owner=None, requirements=None, external=False, **kwargs
|
||||
) -> None:
|
||||
super().__init__(
|
||||
owner=owner, requirements=requirements, external=external, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _fake_manager(tmp_path: Path) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__class__ = lambda: m # _resolve constructs a same-class instance
|
||||
m.get_package.return_value = None
|
||||
m.search_registry_packages.return_value = [{"any": 1}]
|
||||
m.find_best_registry_version.return_value = (
|
||||
{"name": "toolchain-xtensa"},
|
||||
{
|
||||
"name": "2.0.0",
|
||||
"files": [
|
||||
{
|
||||
"download_url": "https://dl.example/t.tar.gz",
|
||||
"checksum": {"sha256": "cafe"},
|
||||
"size": 1000,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
m.pick_compatible_pkg_file.side_effect = lambda files: files[0]
|
||||
m.compute_download_path.side_effect = lambda url, checksum: str(
|
||||
tmp_path / "dl" / f"{abs(hash((url, checksum)))}"
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
def _mirror_patch():
|
||||
return patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio.registry.mirror": SimpleNamespace(
|
||||
RegistryFileMirrorIterator=lambda url: iter(
|
||||
[("https://mirror.example/t.tar.gz", "beef")]
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_registry_jobs_resolves_like_platformio(tmp_path: Path) -> None:
|
||||
"""A registry spec resolves to a job keyed by mirror URL and checksum."""
|
||||
m = _fake_manager(tmp_path)
|
||||
with _mirror_patch():
|
||||
jobs, failed = pf._registry_jobs(
|
||||
m, [_FakeSpec(uri=None, name="toolchain-xtensa")], set()
|
||||
)
|
||||
assert failed == 0
|
||||
assert len(jobs) == 1
|
||||
name, size, fetch = jobs[0]
|
||||
assert name == "toolchain-xtensa@2.0.0"
|
||||
assert size == 1000
|
||||
m.compute_download_path.assert_called_once_with(
|
||||
"https://mirror.example/t.tar.gz", "beef"
|
||||
)
|
||||
assert callable(fetch)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "attr", "value"),
|
||||
[
|
||||
("get_package", "return_value", object()), # already installed
|
||||
("search_registry_packages", "return_value", []), # unknown package
|
||||
("find_best_registry_version", "return_value", (None, None)), # no match
|
||||
("pick_compatible_pkg_file", "side_effect", lambda files: None), # no file
|
||||
],
|
||||
)
|
||||
def test_registry_jobs_skips(tmp_path: Path, method, attr, value) -> None:
|
||||
"""Entries PlatformIO would not download produce no job."""
|
||||
m = _fake_manager(tmp_path)
|
||||
setattr(getattr(m, method), attr, value)
|
||||
with _mirror_patch():
|
||||
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
|
||||
|
||||
|
||||
def test_registry_jobs_skips_cached_and_sizeless(tmp_path: Path) -> None:
|
||||
"""Cached or sizeless files are left to PlatformIO."""
|
||||
m = _fake_manager(tmp_path)
|
||||
dl = Path(m.compute_download_path("https://mirror.example/t.tar.gz", "beef"))
|
||||
dl.parent.mkdir(parents=True, exist_ok=True)
|
||||
dl.touch()
|
||||
with _mirror_patch():
|
||||
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
|
||||
dl.unlink()
|
||||
m.find_best_registry_version.return_value[1]["files"][0]["size"] = 0
|
||||
with _mirror_patch():
|
||||
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
|
||||
|
||||
|
||||
def test_registry_jobs_dedupes_download_paths(tmp_path: Path) -> None:
|
||||
"""Duplicate specs resolve once and one archive yields one job (two
|
||||
workers must never share a .part); nine specs against eight workers
|
||||
also exercise the thread-local manager reuse."""
|
||||
m = _fake_manager(tmp_path)
|
||||
specs = [_FakeSpec(uri=None, name="dup"), _FakeSpec(uri=None, name="dup")]
|
||||
specs += [_FakeSpec(uri=None, name=f"n{i}") for i in range(8)]
|
||||
with _mirror_patch():
|
||||
jobs, failed = pf._registry_jobs(m, specs, set())
|
||||
# the fake resolves every spec to the same mirror URL and checksum
|
||||
assert failed == 0
|
||||
assert len(jobs) == 1
|
||||
assert m.search_registry_packages.call_count == 9 # dup resolved once
|
||||
|
||||
|
||||
def test_registry_jobs_uri_specs_excluded(tmp_path: Path) -> None:
|
||||
"""URL specs never reach the registry resolution."""
|
||||
m = _fake_manager(tmp_path)
|
||||
assert pf._registry_jobs(
|
||||
m, [_FakeSpec(uri="https://x/y.zip", name="y")], set()
|
||||
) == ([], 0)
|
||||
m.search_registry_packages.assert_not_called()
|
||||
|
||||
|
||||
def test_registry_jobs_dedup_keeps_distinct_owners(tmp_path: Path) -> None:
|
||||
"""platformio/x and pioarduino/x are different packages."""
|
||||
m = _fake_manager(tmp_path)
|
||||
specs = [
|
||||
_FakeSpec(uri=None, name="framework-x", owner="platformio"),
|
||||
_FakeSpec(uri=None, name="framework-x", owner="pioarduino"),
|
||||
]
|
||||
with _mirror_patch():
|
||||
pf._registry_jobs(m, specs, set())
|
||||
assert m.search_registry_packages.call_count == 2
|
||||
|
||||
|
||||
def test_registry_jobs_all_failed_warns_once(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A whole-batch failure is a systemic fault and must be visible."""
|
||||
m = _fake_manager(tmp_path)
|
||||
m.search_registry_packages.side_effect = RuntimeError("registry down")
|
||||
with _mirror_patch():
|
||||
jobs, failed = pf._registry_jobs(
|
||||
m,
|
||||
[_FakeSpec(uri=None, name="a"), _FakeSpec(uri=None, name="b")],
|
||||
set(),
|
||||
)
|
||||
assert (jobs, failed) == ([], 2)
|
||||
# The aggregate warning names a cause so an API break does not read
|
||||
# as a registry outage
|
||||
assert "Could not resolve 2 of 2" in caplog.text
|
||||
assert "registry down" in caplog.text
|
||||
|
||||
|
||||
def test_uri_fetch_job_promotes_atomically(tmp_path: Path) -> None:
|
||||
"""Checksum-less URL archives land via a locked staging file and an
|
||||
atomic rename (the stable name is what keeps .part resume working)."""
|
||||
dl_path = tmp_path / "archive"
|
||||
|
||||
def fake_download(url, dest, progress=None, **kwargs):
|
||||
Path(dest).write_bytes(b"data")
|
||||
|
||||
manager = MagicMock()
|
||||
with patch(
|
||||
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
|
||||
):
|
||||
pf._uri_fetch_job(manager, "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
assert dl_path.read_bytes() == b"data"
|
||||
# The archive is handed to pio's usage.db pruner
|
||||
manager.set_download_utime.assert_called_once_with(str(dl_path))
|
||||
# no orphaned staging file; the lock file may or may not persist
|
||||
# (filelock removes it on release on some platforms)
|
||||
leftovers = {f.name for f in tmp_path.iterdir()}
|
||||
assert leftovers - {f"{dl_path.name}.prefetch.lock"} == {dl_path.name}
|
||||
|
||||
|
||||
def test_registry_fetch_job_skips_when_cached(tmp_path: Path) -> None:
|
||||
"""A destination another process completed is not re-downloaded."""
|
||||
dl_path = tmp_path / "archive"
|
||||
dl_path.write_bytes(b"done")
|
||||
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
|
||||
pf._registry_fetch_job(
|
||||
MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4
|
||||
)(lambda done: None)
|
||||
mock_download.assert_not_called()
|
||||
assert dl_path.read_bytes() == b"done"
|
||||
|
||||
|
||||
def test_registry_fetch_job_downloads_under_lock(tmp_path: Path) -> None:
|
||||
"""Registry downloads write the shared cache path under the same lock
|
||||
the URL path uses; interleaved writers would corrupt the archive."""
|
||||
dl_path = tmp_path / "archive"
|
||||
order: list[str] = []
|
||||
with (
|
||||
patch(
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=lambda url, dest, progress=None, **kw: (
|
||||
order.append("fetch"),
|
||||
Path(dest).write_bytes(b"data"), # registration needs a real file
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"filelock.FileLock.acquire",
|
||||
side_effect=lambda *a, **k: order.append("lock"),
|
||||
),
|
||||
patch(
|
||||
"filelock.FileLock.release",
|
||||
side_effect=lambda *a, **k: order.append("unlock"),
|
||||
),
|
||||
):
|
||||
manager = MagicMock()
|
||||
pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)(
|
||||
lambda done: None
|
||||
)
|
||||
# FileLock.__del__ may add a trailing release; the contract is the order
|
||||
assert order[:2] == ["lock", "fetch"]
|
||||
assert "unlock" in order[2:]
|
||||
manager.set_download_utime.assert_called_once_with(str(dl_path))
|
||||
|
||||
|
||||
def test_lockless_filesystem_downloads_unlocked(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A filesystem without lock support (ENOSYS/EPERM) degrades to an
|
||||
unlocked download with one warning, never a per-package failure."""
|
||||
dl_path = tmp_path / "archive"
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch(
|
||||
"filelock.FileLock.acquire",
|
||||
side_effect=OSError(errno.ENOSYS, "no locks"),
|
||||
),
|
||||
patch("filelock.FileLock.release"),
|
||||
):
|
||||
pf._registry_fetch_job(
|
||||
MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4
|
||||
)(lambda done: None)
|
||||
mock_download.assert_called_once()
|
||||
assert "downloading unlocked" in caplog.text
|
||||
|
||||
|
||||
def test_uri_fetch_job_failed_download_keeps_staging(tmp_path: Path) -> None:
|
||||
"""A failed fetch keeps the .part staging bytes for the next resume."""
|
||||
dl_path = tmp_path / "archive"
|
||||
part = tmp_path / "archive.prefetch.part"
|
||||
part.write_bytes(b"partial")
|
||||
with (
|
||||
patch(
|
||||
"esphome.framework_helpers.download_with_resume",
|
||||
side_effect=OSError("network gone"),
|
||||
),
|
||||
pytest.raises(OSError, match="network gone"),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
assert part.read_bytes() == b"partial"
|
||||
assert not dl_path.exists()
|
||||
|
||||
|
||||
def test_uri_fetch_job_rejects_wrong_length(tmp_path: Path) -> None:
|
||||
"""A checksum-less body of the wrong length is never published under a
|
||||
cache key pio would trust forever."""
|
||||
dl_path = tmp_path / "archive"
|
||||
|
||||
def fake_download(url, dest, progress=None, **kwargs):
|
||||
Path(dest).write_bytes(b"short")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
|
||||
),
|
||||
pytest.raises(ValueError, match="expected 9999 bytes"),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 9999)(
|
||||
lambda done: None
|
||||
)
|
||||
assert not dl_path.exists()
|
||||
assert not (tmp_path / "archive.prefetch").exists()
|
||||
|
||||
|
||||
def test_sweep_stale_sidecars(tmp_path: Path) -> None:
|
||||
"""Sidecars past pio's own expiry are pruned; fresh and foreign files
|
||||
stay."""
|
||||
old_time = pf.time.time() - 110
|
||||
stale = tmp_path / "a.tar.gz.part"
|
||||
stale.write_bytes(b"x")
|
||||
os.utime(stale, (old_time, old_time))
|
||||
fresh = tmp_path / "b.tar.gz.part"
|
||||
fresh.write_bytes(b"x")
|
||||
keep = tmp_path / "c.tar.gz"
|
||||
keep.write_bytes(b"x")
|
||||
os.utime(keep, (old_time, old_time))
|
||||
# A held lock can carry an ancient mtime (O_TRUNC keeps it); locks
|
||||
# must never be swept or the single-writer guarantee reopens
|
||||
held_lock = tmp_path / "d.tar.gz.esphome.lock"
|
||||
held_lock.write_bytes(b"")
|
||||
os.utime(held_lock, (old_time, old_time))
|
||||
pf._sweep_stale_sidecars(tmp_path, 100)
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert keep.exists()
|
||||
assert held_lock.exists()
|
||||
pf._sweep_stale_sidecars(tmp_path / "missing", 100) # tolerated
|
||||
|
||||
|
||||
def test_register_download_failure_leaves_a_trace(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed usage.db registration is traced; an unregistered archive
|
||||
is never pruned, so silence would hide the leak coming back."""
|
||||
manager = MagicMock()
|
||||
manager.set_download_utime.side_effect = RuntimeError("db locked")
|
||||
with caplog.at_level(pf.logging.DEBUG):
|
||||
pf._register_download(manager, tmp_path / "a.tar.gz")
|
||||
assert "Could not register" in caplog.text
|
||||
|
||||
|
||||
def test_sweep_logs_unprunable_files(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A sidecar that cannot be removed leaves a trace; a sweep that never
|
||||
prunes must not look like a clean sweep."""
|
||||
old_time = pf.time.time() - 110
|
||||
stale = tmp_path / "a.tar.gz.part"
|
||||
stale.write_bytes(b"x")
|
||||
os.utime(stale, (old_time, old_time))
|
||||
with (
|
||||
patch.object(Path, "unlink", side_effect=OSError("busy")),
|
||||
caplog.at_level(pf.logging.DEBUG),
|
||||
):
|
||||
pf._sweep_stale_sidecars(tmp_path, 100)
|
||||
assert "Could not remove" in caplog.text
|
||||
|
||||
|
||||
def test_uri_lock_failure_is_a_counted_failure(tmp_path: Path) -> None:
|
||||
"""The checksum-less URL path never degrades to an unlocked shared
|
||||
write; interleaved right-length corruption would go undetected."""
|
||||
dl_path = tmp_path / "archive"
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch(
|
||||
"filelock.FileLock.acquire",
|
||||
side_effect=OSError(errno.ENOSYS, "no locks"),
|
||||
),
|
||||
pytest.raises(OSError),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_uri_fetch_job_no_discard_without_a_file(tmp_path: Path) -> None:
|
||||
"""When no archive landed (degraded serialized run), the staging bytes
|
||||
stay for the next resume instead of being discarded."""
|
||||
dl_path = tmp_path / "archive"
|
||||
part = tmp_path / "archive.prefetch.part"
|
||||
part.write_bytes(b"partial")
|
||||
with patch.object(pf, "_serialized_fetch_job", return_value=lambda tracker: None):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
assert part.read_bytes() == b"partial"
|
||||
|
||||
|
||||
def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None:
|
||||
"""A lock freed within the deadline lets the job proceed normally."""
|
||||
dl_path = tmp_path / "archive"
|
||||
|
||||
def fake_download(url, dest, progress=None, **kwargs):
|
||||
Path(dest).write_bytes(b"data")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
|
||||
),
|
||||
patch("filelock.FileLock.acquire", side_effect=[Timeout("held"), None]),
|
||||
patch("filelock.FileLock.release"),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
assert dl_path.read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None:
|
||||
"""A lock held past the deadline means another process is fetching the
|
||||
same file; skipping cleanly beats a misleading failure warning. The
|
||||
tracker is still polled so a parked worker observes cancellation."""
|
||||
dl_path = tmp_path / "archive"
|
||||
ticks: list[int] = []
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
|
||||
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
):
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append)
|
||||
mock_download.assert_not_called()
|
||||
assert ticks == [0]
|
||||
assert not dl_path.exists()
|
||||
|
||||
|
||||
def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None:
|
||||
"""A registry job that lost the download race to another process
|
||||
must not stamp a nonexistent archive into pio's usage.db."""
|
||||
manager = MagicMock()
|
||||
dl_path = tmp_path / "archive"
|
||||
with (
|
||||
patch("esphome.framework_helpers.download_with_resume") as mock_download,
|
||||
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
|
||||
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
|
||||
):
|
||||
pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)(
|
||||
lambda done: None
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
manager.set_download_utime.assert_not_called()
|
||||
|
||||
|
||||
def test_main_interrupt_exits_quietly(tmp_path: Path) -> None:
|
||||
"""Ctrl-C reaches the child via the shared process group; it must exit
|
||||
without a traceback."""
|
||||
with (
|
||||
patch("esphome.log.setup_log"),
|
||||
patch.object(pf, "_prefetch", side_effect=KeyboardInterrupt),
|
||||
):
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 130
|
||||
|
||||
|
||||
def test_main_bad_log_level_falls_back(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.dict("os.environ", {"ESPHOME_PREFETCH_LOG_LEVEL": "verbose"}),
|
||||
patch("esphome.log.setup_log") as mock_setup,
|
||||
patch.object(pf, "_prefetch"),
|
||||
):
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 0
|
||||
assert mock_setup.call_args[0][0] == pf.logging.INFO
|
||||
|
||||
|
||||
def test_main_silences_pio_manager_propagation(tmp_path: Path) -> None:
|
||||
"""The pio manager loggers carry their own handler; propagation to
|
||||
the root handler would print every install line twice."""
|
||||
with patch("esphome.log.setup_log"), patch.object(pf, "_prefetch"):
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 0
|
||||
for name in ("Tool Manager", "Library Manager", "Platform Manager"):
|
||||
assert pf.logging.getLogger(name).propagate is False
|
||||
|
||||
|
||||
def test_main_quiet_level_reaches_pio_manager_loggers(tmp_path: Path) -> None:
|
||||
"""Manager construction re-pins its logger to INFO, so a quiet run
|
||||
needs the logger-level filter to keep per-package lines out."""
|
||||
with (
|
||||
patch.dict("os.environ", {"ESPHOME_PREFETCH_LOG_LEVEL": "30"}),
|
||||
patch("esphome.log.setup_log"),
|
||||
patch.object(pf, "_prefetch"),
|
||||
):
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 0
|
||||
lib_logger = pf.logging.getLogger("Library Manager")
|
||||
lib_logger.setLevel(pf.logging.INFO) # what pio's _setup_logger does
|
||||
info = pf.logging.LogRecord("Library Manager", 20, __file__, 1, "x", (), None)
|
||||
warning = pf.logging.LogRecord("Library Manager", 30, __file__, 1, "x", (), None)
|
||||
# Logger.filter returns falsy to drop, the record itself to pass
|
||||
assert not lib_logger.filter(info)
|
||||
assert lib_logger.filter(warning)
|
||||
|
||||
|
||||
def test_main_mirrors_parent_log_setup(tmp_path: Path) -> None:
|
||||
"""The child adopts the parent's dashboard flag and log formatter so
|
||||
its warnings and progress bar match the parent's."""
|
||||
with (
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"ESPHOME_PREFETCH_LOG_LEVEL": "30", "ESPHOME_PREFETCH_DASHBOARD": "1"},
|
||||
),
|
||||
patch("esphome.log.setup_log") as mock_setup,
|
||||
patch.object(pf, "_prefetch"),
|
||||
):
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 0
|
||||
mock_setup.assert_called_once_with(30)
|
||||
assert CORE.dashboard is True
|
||||
|
||||
|
||||
def test_uri_fetch_job_skips_when_another_process_won(tmp_path: Path) -> None:
|
||||
"""A lost race discards the staging files; the cache never prunes them."""
|
||||
dl_path = tmp_path / "archive"
|
||||
dl_path.write_bytes(b"done")
|
||||
stale = [
|
||||
tmp_path / "archive.prefetch",
|
||||
tmp_path / "archive.prefetch.part",
|
||||
tmp_path / "archive.prefetch.part.meta",
|
||||
]
|
||||
for f in stale:
|
||||
f.write_bytes(b"stale")
|
||||
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
|
||||
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
|
||||
mock_download.assert_not_called()
|
||||
assert dl_path.read_bytes() == b"done"
|
||||
assert not any(f.exists() for f in stale)
|
||||
|
||||
|
||||
def test_registry_jobs_one_bad_spec_keeps_the_rest(tmp_path: Path) -> None:
|
||||
"""A flaky resolution counts as failed without discarding the batch."""
|
||||
m = _fake_manager(tmp_path)
|
||||
m.search_registry_packages.side_effect = [
|
||||
RuntimeError("registry 500"),
|
||||
[{"any": 1}],
|
||||
]
|
||||
with _mirror_patch():
|
||||
jobs, failed = pf._registry_jobs(
|
||||
m,
|
||||
[_FakeSpec(uri=None, name="flaky"), _FakeSpec(uri=None, name="good")],
|
||||
set(),
|
||||
)
|
||||
assert failed == 1
|
||||
assert len(jobs) == 1
|
||||
|
||||
|
||||
def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
|
||||
"""HEAD sizes direct-URL specs; git and unreachable URLs are skipped."""
|
||||
m = _fake_manager(tmp_path)
|
||||
resp = MagicMock()
|
||||
resp.headers = {"content-length": "2222"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
jobs, failed = pf._uri_jobs(
|
||||
m,
|
||||
[
|
||||
_FakeSpec(uri="https://x/big.zip", name="big"),
|
||||
_FakeSpec(uri="git+https://x/repo.git", name="repo"),
|
||||
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
|
||||
_FakeSpec(uri=None, name="registry"),
|
||||
],
|
||||
set(),
|
||||
)
|
||||
assert failed == 0
|
||||
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
|
||||
# a successful HEAD with no Content-Length is a clean skip
|
||||
resp.headers = {}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
assert pf._uri_jobs(
|
||||
m, [_FakeSpec(uri="https://x/nolen.zip", name="nolen")], set()
|
||||
) == ([], 0)
|
||||
|
||||
|
||||
def test_uri_jobs_head_failure_counts_as_unresolved(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Network errors and transient statuses count as unresolved (and warn);
|
||||
any permanent error status is a clean skip so the sentinel can still
|
||||
be written (pio run names a broken URL when it downloads)."""
|
||||
m = _fake_manager(tmp_path)
|
||||
spec = [_FakeSpec(uri="https://x/a.zip", name="a")]
|
||||
with patch("esphome.net_retry.http_request", side_effect=OSError("no route")):
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 1)
|
||||
resp = MagicMock(ok=False, status_code=503)
|
||||
resp.headers = {"content-length": "999"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 1)
|
||||
# 403 is how registries rate-limit; it must not be cached as warm
|
||||
resp = MagicMock(ok=False, status_code=403)
|
||||
resp.headers = {"content-length": "999"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 1)
|
||||
resp = MagicMock(ok=False, status_code=405)
|
||||
resp.headers = {"content-length": "999"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 0)
|
||||
assert "HEAD https://x/a.zip" not in caplog.text
|
||||
resp = MagicMock(ok=False, status_code=404)
|
||||
resp.headers = {"content-length": "999"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp):
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 0)
|
||||
assert "returned 404" not in caplog.text
|
||||
|
||||
|
||||
def test_uri_jobs_dedupes_duplicate_urls(tmp_path: Path) -> None:
|
||||
"""Two specs with one URL yield one HEAD and one job."""
|
||||
m = _fake_manager(tmp_path)
|
||||
resp = MagicMock()
|
||||
resp.headers = {"content-length": "5"}
|
||||
with patch("esphome.net_retry.http_request", return_value=resp) as mock_head:
|
||||
jobs, failed = pf._uri_jobs(
|
||||
m,
|
||||
[
|
||||
_FakeSpec(uri="https://x/a.zip", name="a"),
|
||||
_FakeSpec(uri="https://x/a.zip", name="a"),
|
||||
],
|
||||
set(),
|
||||
)
|
||||
assert failed == 0
|
||||
assert len(jobs) == 1
|
||||
mock_head.assert_called_once()
|
||||
|
||||
|
||||
def test_uri_jobs_skips_installed_cached_and_seen(tmp_path: Path) -> None:
|
||||
m = _fake_manager(tmp_path)
|
||||
m.get_package.return_value = object()
|
||||
spec = [_FakeSpec(uri="https://x/a.zip", name="a")]
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 0)
|
||||
m.get_package.return_value = None
|
||||
dl = Path(m.compute_download_path("https://x/a.zip", ""))
|
||||
dl.parent.mkdir(parents=True, exist_ok=True)
|
||||
dl.touch()
|
||||
assert pf._uri_jobs(m, spec, set()) == ([], 0)
|
||||
dl.unlink()
|
||||
# a registry job already claimed this download path
|
||||
assert pf._uri_jobs(m, spec, {str(dl)}) == ([], 0)
|
||||
|
||||
|
||||
def test_prefetch_spawns_isolated_subprocess(tmp_path: Path) -> None:
|
||||
"""Heal runs first, then the subprocess spawns with pio run's libdeps
|
||||
dir and the parent's PYTHONPATH preserved (the child is esphome)."""
|
||||
proc = MagicMock(returncode=0)
|
||||
order = MagicMock()
|
||||
order.run.return_value = proc
|
||||
with (
|
||||
patch(
|
||||
"esphome.platformio.toolchain.heal_platformio_python_env",
|
||||
order.heal,
|
||||
),
|
||||
patch.object(pf.subprocess, "run", order.run) as mock_run,
|
||||
patch.dict("os.environ", {"PYTHONPATH": "/leak"}),
|
||||
):
|
||||
pf.prefetch_platformio_packages()
|
||||
assert [c[0] for c in order.mock_calls[:2]] == ["heal", "run"]
|
||||
(cmd,), kwargs = mock_run.call_args
|
||||
assert cmd == [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome.platformio.prefetch",
|
||||
str(CORE.build_path),
|
||||
"testenv",
|
||||
]
|
||||
assert kwargs["env"]["PLATFORMIO_LIBDEPS_DIR"] == str(
|
||||
CORE.relative_piolibdeps_path().absolute()
|
||||
)
|
||||
# The child is esphome itself; PYTHONPATH must survive so it imports
|
||||
# the same tree (tests/integration pins the source tree through it)
|
||||
assert kwargs["env"]["PYTHONPATH"] == "/leak"
|
||||
assert "ESPHOME_PREFETCH_DASHBOARD" not in kwargs["env"]
|
||||
assert kwargs["timeout"] == pf._PREFETCH_TIMEOUT
|
||||
|
||||
|
||||
def test_prefetch_passes_dashboard_flag(tmp_path: Path) -> None:
|
||||
"""The dashboard flag reaches the child so its bar still draws."""
|
||||
CORE.dashboard = True
|
||||
with (
|
||||
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
|
||||
patch.object(
|
||||
pf.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
pf.prefetch_platformio_packages()
|
||||
assert mock_run.call_args[1]["env"]["ESPHOME_PREFETCH_DASHBOARD"] == "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("run_effect", "expected"),
|
||||
[
|
||||
(
|
||||
{"side_effect": pf.subprocess.TimeoutExpired("cmd", pf._PREFETCH_TIMEOUT)},
|
||||
"prefetch timed out",
|
||||
),
|
||||
({"return_value": MagicMock(returncode=4)}, "prefetch skipped (exit 4)"),
|
||||
# Exit 1 is the interpreter's own import-failure code, never quiet
|
||||
({"return_value": MagicMock(returncode=1)}, "prefetch skipped (exit 1)"),
|
||||
({"side_effect": OSError("no exec")}, "PlatformIO package prefetch skipped"),
|
||||
],
|
||||
)
|
||||
def test_prefetch_spawn_failures_warn_and_continue(
|
||||
caplog: pytest.LogCaptureFixture, run_effect, expected
|
||||
) -> None:
|
||||
"""Timeouts, nonzero exits, and spawn failures each warn, never raise."""
|
||||
with (
|
||||
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
|
||||
patch.object(pf.subprocess, "run", **run_effect),
|
||||
):
|
||||
pf.prefetch_platformio_packages()
|
||||
assert expected in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_child_handled_failure_is_quiet(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Exit _EXIT_HANDLED (3) means the child already warned with the
|
||||
reason; the parent adds no second warning."""
|
||||
with (
|
||||
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
|
||||
patch.object(
|
||||
pf.subprocess, "run", return_value=MagicMock(returncode=pf._EXIT_HANDLED)
|
||||
),
|
||||
):
|
||||
pf.prefetch_platformio_packages()
|
||||
assert "prefetch skipped" not in caplog.text
|
||||
|
||||
|
||||
def test_main_guards_and_exits_nonzero(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A swallowed failure still reaches the parent as a nonzero exit; the
|
||||
parent warns and continues, never failing the build."""
|
||||
with patch.object(pf, "_prefetch", side_effect=RuntimeError("boom")):
|
||||
assert pf.main(["/b", "testenv"]) == pf._EXIT_HANDLED
|
||||
assert "PlatformIO package prefetch skipped" in caplog.text
|
||||
|
||||
|
||||
def test_main_runs_prefetch(tmp_path: Path) -> None:
|
||||
with patch.object(pf, "_prefetch") as mock_prefetch:
|
||||
assert pf.main([str(tmp_path), "testenv"]) == 0
|
||||
mock_prefetch.assert_called_once_with(tmp_path, "testenv")
|
||||
|
||||
|
||||
def test_main_bad_argv_is_a_distinct_exit(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A parent/child wiring bug must not look like a network failure."""
|
||||
with patch.object(pf, "_prefetch") as mock_prefetch:
|
||||
assert pf.main(["only-one"]) == 2
|
||||
mock_prefetch.assert_not_called()
|
||||
assert "prefetch usage" in caplog.text
|
||||
|
||||
|
||||
def _write_ini(tmp_path: Path, body: str) -> None:
|
||||
(tmp_path / "platformio.ini").write_text(body)
|
||||
|
||||
|
||||
def _write_valid_sentinel(tmp_path: Path, dirs: list[str]) -> None:
|
||||
(tmp_path / pf._SENTINEL_NAME).write_text(
|
||||
json.dumps({**pf._sentinel_state(tmp_path), "dirs": dirs}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_prefetch_no_platform_returns(tmp_path: Path) -> None:
|
||||
_write_ini(tmp_path, "[env:testenv]\n")
|
||||
with patch.object(pf, "_registry_jobs") as mock_jobs:
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
mock_jobs.assert_not_called()
|
||||
|
||||
|
||||
def _pio_modules(tmp_path: Path, fake_platform, fake_pm, config, lib_captures=None):
|
||||
# A bare MagicMock's get_download_dir would fspath to '' and point the
|
||||
# sidecar sweep at the process cwd
|
||||
fake_pm.get_download_dir.return_value = str(tmp_path / "downloads")
|
||||
fake_pm.DOWNLOAD_CACHE_EXPIRE = 86400 * 30
|
||||
|
||||
def fake_lib_manager(storage_dir):
|
||||
if lib_captures is not None:
|
||||
lib_captures.append(storage_dir)
|
||||
return _fake_manager(tmp_path)
|
||||
|
||||
modules = {
|
||||
"platformio": MagicMock(),
|
||||
"platformio.app": MagicMock(),
|
||||
"platformio.project": MagicMock(),
|
||||
"platformio.project.config": MagicMock(),
|
||||
"platformio.dependencies": SimpleNamespace(
|
||||
get_core_dependencies=lambda: {
|
||||
"tool-scons": "~4.0",
|
||||
"contrib-piohome": "~3",
|
||||
}
|
||||
),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.manager": MagicMock(),
|
||||
"platformio.package.manager.library": SimpleNamespace(
|
||||
LibraryPackageManager=fake_lib_manager
|
||||
),
|
||||
"platformio.package.manager.platform": SimpleNamespace(
|
||||
PlatformPackageManager=lambda: fake_pm
|
||||
),
|
||||
"platformio.package.meta": SimpleNamespace(
|
||||
PackageSpec=lambda *a, **kw: _FakeSpec(
|
||||
uri=None,
|
||||
name=kw.get("name") or (a[0] if a else None),
|
||||
owner=kw.get("owner")
|
||||
or (str(a[0]).split("/")[0] if a and "/" in str(a[0]) else None),
|
||||
external=bool(a and "://" in str(a[0])),
|
||||
)
|
||||
),
|
||||
"platformio.platform": MagicMock(),
|
||||
"platformio.platform.factory": SimpleNamespace(
|
||||
PlatformFactory=SimpleNamespace(new=lambda pkg: fake_platform)
|
||||
),
|
||||
}
|
||||
modules[
|
||||
"platformio.project.config"
|
||||
].ProjectConfig.get_instance.return_value = config
|
||||
return modules
|
||||
|
||||
|
||||
def _fake_config(tmp_path: Path, env_options: dict):
|
||||
config = MagicMock()
|
||||
options = {
|
||||
"libdeps_dir": str(tmp_path / "libdeps"),
|
||||
"packages_dir": str(tmp_path / "packages"),
|
||||
**env_options,
|
||||
}
|
||||
config.get.side_effect = lambda section, key, default=None: options.get(
|
||||
key, default
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def test_prefetch_all_cached_is_quiet_and_writes_sentinel(tmp_path: Path) -> None:
|
||||
"""A no-work run neither logs nor batches, and records the sentinel."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
(tmp_path / "packages").mkdir()
|
||||
(tmp_path / "libdeps" / "testenv").mkdir(parents=True)
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
config = _fake_config(
|
||||
tmp_path, {"platform": "fake/p@1", "lib_deps": ["esphome/noise-c@1.0"]}
|
||||
)
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(pf, "_registry_jobs", return_value=([], 0)),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0)),
|
||||
patch.object(pf, "run_batch_downloads") as mock_batch,
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
mock_batch.assert_not_called()
|
||||
assert pf._prefetch_is_warm(tmp_path)
|
||||
|
||||
|
||||
def test_prefetch_failed_resolution_is_not_cached_as_warm(tmp_path: Path) -> None:
|
||||
"""A registry outage must not write the sentinel."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
(tmp_path / "packages").mkdir()
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(pf, "_registry_jobs", return_value=([], 1)),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0)),
|
||||
patch.object(pf, "run_batch_downloads") as mock_batch,
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
mock_batch.assert_not_called()
|
||||
assert not (tmp_path / pf._SENTINEL_NAME).exists()
|
||||
|
||||
|
||||
def test_sentinel_invalidation(tmp_path: Path) -> None:
|
||||
"""Ini changes, missing dirs, and garbage sentinels all read as cold."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
pkg_dir = tmp_path / "packages"
|
||||
pkg_dir.mkdir()
|
||||
assert not pf._prefetch_is_warm(tmp_path) # no sentinel yet
|
||||
_write_valid_sentinel(tmp_path, [str(pkg_dir)])
|
||||
assert pf._prefetch_is_warm(tmp_path)
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@2\n")
|
||||
assert not pf._prefetch_is_warm(tmp_path) # ini changed
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
pkg_dir.rmdir()
|
||||
assert not pf._prefetch_is_warm(tmp_path) # recorded dir gone
|
||||
(tmp_path / pf._SENTINEL_NAME).write_text("not json", encoding="utf-8")
|
||||
assert not pf._prefetch_is_warm(tmp_path)
|
||||
|
||||
|
||||
def test_prefetch_warm_sentinel_skips_spawn(tmp_path: Path) -> None:
|
||||
"""A valid sentinel skips the subprocess entirely."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
pkg_dir = tmp_path / "packages"
|
||||
pkg_dir.mkdir()
|
||||
_write_valid_sentinel(tmp_path, [str(pkg_dir)])
|
||||
with (
|
||||
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
|
||||
patch.object(pf.subprocess, "run") as mock_run,
|
||||
):
|
||||
pf.prefetch_platformio_packages()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_end_to_end_wiring(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Platform installs dep-free, non-optional packages plus tool-scons
|
||||
resolve, libraries use the env libdeps dir, a platform sys.path rewrite
|
||||
is undone, and failures warn by name."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/platform@1.0\n")
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {
|
||||
"toolchain-x": {"optional": False},
|
||||
"framework-y": {"optional": True},
|
||||
}
|
||||
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
|
||||
uri=None, name=name
|
||||
)
|
||||
# Platform setup code rewrites sys.path (pioarduino penv); _prefetch
|
||||
# must restore it
|
||||
bogus = str(tmp_path / "penv-site-packages")
|
||||
fake_platform.configure_project_packages.side_effect = lambda env, targets: (
|
||||
sys.path.insert(0, bogus)
|
||||
)
|
||||
fake_pm = MagicMock()
|
||||
config = _fake_config(
|
||||
tmp_path,
|
||||
{
|
||||
"platform": "fake/platform@1.0",
|
||||
# the bare built-in name and the interpolation are skipped;
|
||||
# only the owner-qualified library resolves
|
||||
"lib_deps": ["esphome/noise-c@1.0", "WiFi", "${common.lib_deps}"],
|
||||
},
|
||||
)
|
||||
lib_dirs: list[str] = []
|
||||
modules = _pio_modules(tmp_path, fake_platform, fake_pm, config, lib_dirs)
|
||||
(tmp_path / pf._SENTINEL_NAME).write_text("{}", encoding="utf-8")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_registry_jobs(manager, specs, seen):
|
||||
captured.setdefault("spec_batches", []).append([s.name for s in specs])
|
||||
return [("toolchain-x@1", 10, lambda t: None)], 0
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(pf, "_registry_jobs", side_effect=fake_registry_jobs),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0)),
|
||||
patch.object(
|
||||
pf,
|
||||
"run_batch_downloads",
|
||||
return_value=[("toolchain-x@1", OSError("down"))],
|
||||
) as mock_batch,
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
fake_pm.install.assert_called_once_with("fake/platform@1.0", skip_dependencies=True)
|
||||
assert not (tmp_path / pf._SENTINEL_NAME).exists() # stale sentinel removed
|
||||
fake_platform.configure_project_packages.assert_called_once_with("testenv", ["run"])
|
||||
assert bogus not in sys.path
|
||||
# non-optional platform package + tool-scons (never piohome), then libs
|
||||
assert captured["spec_batches"][0] == ["toolchain-x", "tool-scons"]
|
||||
assert captured["spec_batches"][1] == ["esphome/noise-c@1.0"]
|
||||
assert lib_dirs == [str(Path(tmp_path / "libdeps") / "testenv")]
|
||||
mock_batch.assert_called_once()
|
||||
assert "Could not prefetch toolchain-x@1" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_skips_duplicate_tool_scons(tmp_path: Path) -> None:
|
||||
"""A platform that lists tool-scons itself does not get it appended."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {"tool-scons": {"optional": False}}
|
||||
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
|
||||
uri=None, name=name
|
||||
)
|
||||
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
batches: list[list[str]] = []
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=lambda mgr, specs, seen: (
|
||||
batches.append([s.name for s in specs]) or ([], 0)
|
||||
),
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0)),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert batches[0] == ["tool-scons"]
|
||||
@@ -932,8 +932,13 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non
|
||||
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
|
||||
mock_run_platformio_cli_run.return_value = 0
|
||||
|
||||
toolchain.run_compile(config, verbose=True)
|
||||
with patch(
|
||||
"esphome.platformio.prefetch.prefetch_platformio_packages"
|
||||
) as mock_prefetch:
|
||||
toolchain.run_compile(config, verbose=True)
|
||||
|
||||
# The only wiring of the prefetch into a build lives here
|
||||
mock_prefetch.assert_called_once_with()
|
||||
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
|
||||
|
||||
|
||||
@@ -947,7 +952,8 @@ def test_run_compile_without_process_limit(
|
||||
config = {CONF_ESPHOME: {}}
|
||||
mock_run_platformio_cli_run.return_value = 0
|
||||
|
||||
toolchain.run_compile(config, verbose=False)
|
||||
with patch("esphome.platformio.prefetch.prefetch_platformio_packages"):
|
||||
toolchain.run_compile(config, verbose=False)
|
||||
|
||||
mock_run_platformio_cli_run.assert_called_once_with(config, False)
|
||||
|
||||
@@ -1677,8 +1683,8 @@ def pio_core_dir(tmp_path: Path) -> Path:
|
||||
|
||||
|
||||
def test_current_python_minor_matches_running_interpreter() -> None:
|
||||
"""_current_python_minor returns major.minor of the running interpreter."""
|
||||
assert toolchain._current_python_minor() == _CURRENT_MINOR
|
||||
"""current_python_minor returns major.minor of the running interpreter."""
|
||||
assert toolchain.current_python_minor() == _CURRENT_MINOR
|
||||
|
||||
|
||||
def test_pio_stamp_round_trip(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user