Files
esphome/tests/unit_tests/test_platformio_prefetch.py
T

1732 lines
67 KiB
Python

"""Tests for the parallel PlatformIO package prefetch."""
import errno
import inspect
import json
import logging
import os
from pathlib import Path
import signal
import sys
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from filelock import Timeout
from platformio.package.manager._install import PackageManagerInstallMixin
from platformio.package.manager.base import BasePackageManager
from platformio.package.manager.library import LibraryPackageManager
from platformio.package.manager.platform import PlatformPackageManager
from platformio.package.manager.tool import ToolPackageManager
from platformio.package.meta import PackageCompatibility, PackageSpec
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"
saved_bar = os.environ.get("PLATFORMIO_DISABLE_PROGRESSBAR")
saved_sigterm = signal.getsignal(signal.SIGTERM)
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
# _preinstall and main() set these process-wide; keep the suite hermetic
if saved_bar is None:
os.environ.pop("PLATFORMIO_DISABLE_PROGRESSBAR", None)
else:
os.environ["PLATFORMIO_DISABLE_PROGRESSBAR"] = saved_bar
signal.signal(signal.SIGTERM, saved_sigterm)
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,
*,
uri=None,
owner=None,
requirements=None,
external=False,
custom_name=False,
**kwargs,
) -> None:
super().__init__(
uri=uri, owner=owner, requirements=requirements, external=external, **kwargs
)
self._custom_name = custom_name
def has_custom_name(self) -> bool:
return self._custom_name
def _fake_manager(tmp_path: Path) -> MagicMock:
m = MagicMock()
# _resolve and _preinstall construct same-class instances
m.__class__ = lambda package_dir=None, **kwargs: m
m.get_package.return_value = None
m.compatibility = None
m.is_builtin_lib.return_value = False
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, installable = pf._registry_jobs(
m, [_FakeSpec(name="toolchain-xtensa")], set()
)
assert failed == 0
assert len(jobs) == 1
assert [n for n, _ in installable] == ["toolchain-xtensa@2.0.0"]
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(name="x")], set()) == (
[],
0,
[],
)
def test_registry_jobs_skips_cached_and_sizeless(tmp_path: Path) -> None:
"""A cached archive needs no download but is still installable; a
sizeless uncached one is left to PlatformIO entirely."""
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():
jobs, failed, installable = pf._registry_jobs(m, [_FakeSpec(name="x")], set())
assert (jobs, failed) == ([], 0)
assert [n for n, _ in installable] == ["toolchain-xtensa@2.0.0"]
dl.unlink()
m.find_best_registry_version.return_value[1]["files"][0]["size"] = 0
with _mirror_patch():
assert pf._registry_jobs(m, [_FakeSpec(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(name="dup"), _FakeSpec(name="dup")]
specs += [_FakeSpec(name=f"n{i}") for i in range(8)]
with _mirror_patch():
jobs, failed, _installable = 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(name="framework-x", owner="platformio"),
_FakeSpec(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, installable = pf._registry_jobs(
m,
[_FakeSpec(name="a"), _FakeSpec(name="b")],
set(),
)
assert (jobs, failed, installable) == ([], 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, installable = pf._registry_jobs(
m,
[_FakeSpec(name="flaky"), _FakeSpec(name="good")],
set(),
)
assert failed == 1
assert len(jobs) == 1
assert len(installable) == 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, installable = pf._uri_jobs(
m,
[
_FakeSpec(uri="https://x/big.zip", name="big", custom_name=True),
_FakeSpec(uri="git+https://x/repo.git", name="repo"),
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
_FakeSpec(name="registry"),
],
set(),
)
assert failed == 0
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
assert [n for n, _ in installable] == ["big"]
# 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, _installable = 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", custom_name=True)]
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()
# cached: no download job, but still installable
jobs, failed, installable = pf._uri_jobs(m, spec, set())
assert (jobs, failed) == ([], 0)
assert [n for n, _ in installable] == ["a"]
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()
proc.wait.return_value = 0
order = MagicMock()
order.run.return_value = proc
with (
patch(
"esphome.platformio.toolchain.heal_platformio_python_env",
order.heal,
),
patch.object(pf.subprocess, "Popen", 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"]
proc.wait.assert_called_once_with(timeout=pf._PREFETCH_TIMEOUT)
def test_stop_child_windows_never_terminates() -> None:
"""The Windows TerminateProcess cannot reach the SIGTERM handler, so
the graceful arm becomes a plain longer wait."""
proc = MagicMock()
proc.wait.side_effect = [pf.subprocess.TimeoutExpired("x", 1), 0]
with patch.object(pf.sys, "platform", "win32"):
pf._stop_child(proc)
proc.terminate.assert_not_called()
proc.kill.assert_not_called()
def test_stop_child_surviving_child_warns(caplog: pytest.LogCaptureFixture) -> None:
"""A child that outlives kill() may still be writing packages pio run
trusts; that must be visible at default verbosity."""
timeout = pf.subprocess.TimeoutExpired("cmd", 5)
proc = MagicMock()
proc.poll.return_value = None # still running: the wait is announced
proc.wait.side_effect = [timeout, timeout, timeout]
with (
patch.object(pf.sys, "platform", "linux"),
caplog.at_level(pf.logging.INFO),
):
pf._stop_child(proc)
assert "Waiting for the prefetch child" in caplog.text
assert "could not be confirmed stopped" in caplog.text
def test_dependency_entries_isolate_a_bad_manifest(tmp_path: Path) -> None:
"""One unreadable manifest skips that entry only, never the group."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: (
SimpleNamespace(spec=spec)
if getattr(spec, "name", "") in ("bad", "good")
else None
)
def deps_for(pkg):
if pkg.spec.name == "bad":
raise RuntimeError("manifest unreadable")
return [{"owner": "o", "name": "dep", "version": "^1"}]
m.get_pkg_dependencies.side_effect = deps_for
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
entries = pf._dependency_entries(
m,
[
("bad@1", _FakeSpec(name="bad")),
("good@1", _FakeSpec(name="good")),
],
set(),
)
assert [name for name, *_ in entries] == ["dep"]
def test_dependency_entries_skip_nameless_spec(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A dependency whose spec has no name has no destination identity;
the drop is diagnosable under -v."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: (
SimpleNamespace(spec=spec) if getattr(spec, "name", "") == "top" else None
)
m.get_pkg_dependencies.return_value = [{"owner": "o", "version": "^1"}]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=None)
with caplog.at_level(logging.DEBUG):
assert (
pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], set()) == []
)
assert "has no name; left to pio run" in caplog.text
def test_dependency_entries_filter_seen_names(tmp_path: Path) -> None:
"""A dependency already waved under its name is not queued again."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: (
SimpleNamespace(spec=spec) if getattr(spec, "name", "") == "top" else None
)
m.get_pkg_dependencies.return_value = [
{"owner": "o", "name": "dep", "version": "^1"}
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
assert pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], {"dep"}) == []
def test_preinstall_cleanup_cannot_displace_the_inflight_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failing unlock or cwd restore must not replace the pool's own
exception (SIGTERM's SystemExit included) with a downgradeable one."""
m = _fake_manager(tmp_path)
m._install.side_effect = SystemExit(143)
m.unlock.side_effect = RuntimeError("flock broke")
real_chdir = pf.os.chdir
monkeypatch.setattr(pf.os, "chdir", MagicMock(side_effect=OSError("cwd removed")))
try:
with pytest.raises(SystemExit):
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
finally:
monkeypatch.setattr(pf.os, "chdir", real_chdir)
assert "Could not release the manager lock" in caplog.text
def test_preinstall_memcache_failure_leaves_a_trace(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failing cache reset warns and skips the dependency wave; the
wave itself still completes."""
m = _fake_manager(tmp_path)
m.memcache_reset.side_effect = RuntimeError("cache broken")
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
assert "Could not reset the storage cache" in caplog.text
assert "Skipping the dependency wave" in caplog.text
m.get_pkg_dependencies.assert_not_called()
def test_prefetch_wait_failure_degrades(caplog: pytest.LogCaptureFixture) -> None:
"""An unexpected wait() failure warns and continues; the prefetch must
never become a new way for the build to fail."""
proc = MagicMock()
proc.wait.side_effect = [RuntimeError("wait broke"), 0]
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "Popen", return_value=proc),
):
pf.prefetch_platformio_packages()
assert "prefetch skipped" in caplog.text
def test_preinstall_stuck_lock_skips_dependency_wave(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed unlock leaves the lock state unknown; the recursive wave
would install under a lock() that silently no-ops."""
m = _fake_manager(tmp_path)
m.unlock.side_effect = RuntimeError("flock broke")
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
assert "Skipping the dependency wave" in caplog.text
m.get_pkg_dependencies.assert_not_called()
def test_preinstall_lost_cwd_warns_and_skips_wave(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed cwd restore is process-global state loss: it warns and
the rest is left to pio run from a clean process."""
m = _fake_manager(tmp_path)
with patch.object(pf.os, "chdir", side_effect=OSError("cwd gone")):
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
assert "Could not restore the working dir" in caplog.text
assert "Skipping the dependency wave" in caplog.text
m.get_pkg_dependencies.assert_not_called()
def test_stop_child_interrupted_and_still_alive_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An interrupt triggers a best-effort kill, warns when the child
cannot be confirmed dead, and re-raises so the build aborts."""
proc = MagicMock()
proc.wait.side_effect = KeyboardInterrupt()
proc.poll.return_value = None
with pytest.raises(KeyboardInterrupt):
pf._stop_child(proc)
proc.kill.assert_called_once_with()
assert "could not be confirmed stopped" in caplog.text
def test_uri_derived_name_spec_downloads_but_never_installs(tmp_path: Path) -> None:
"""A URL spec whose name is derived from the URI installs into a dir
named by the archive manifest, not the derived name; its archive is
prefetched, but the install stays with pio run."""
m = _fake_manager(tmp_path)
resp = MagicMock(ok=True)
resp.headers = {"content-length": "4"}
with patch("esphome.net_retry.http_request", return_value=resp):
jobs, failed, installable = pf._uri_jobs(
m, [_FakeSpec(uri="https://x/v1.zip", name="v1")], set()
)
assert failed == 0
assert len(jobs) == 1 # still prefetched
assert installable == []
# Cached-from-an-earlier-run archives are skipped the same way
dl = Path(m.compute_download_path("https://x/v1.zip", ""))
dl.parent.mkdir(parents=True, exist_ok=True)
dl.touch()
assert pf._uri_jobs(m, [_FakeSpec(uri="https://x/v1.zip", name="v1")], set()) == (
[],
0,
[],
)
def test_dependency_entries_warn_when_all_reads_fail(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Every manifest read failing is a systematic fault (a pio API
break), not one bad package; the waves must not vanish silently."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: SimpleNamespace(spec=spec)
m.get_pkg_dependencies.side_effect = RuntimeError("api break")
assert (
pf._dependency_entries(
m,
[
("a@1", _FakeSpec(name="a")),
("b@1", _FakeSpec(name="b")),
],
set(),
)
== []
)
assert "Could not read dependencies of 2 of 2" in caplog.text
def _proc(wait_effect) -> MagicMock:
proc = MagicMock()
if isinstance(wait_effect, BaseException):
proc.wait.side_effect = [wait_effect, 0]
else:
proc.wait.return_value = wait_effect
return proc
def test_prefetch_passes_dashboard_flag(tmp_path: Path) -> None:
"""The dashboard flag reaches the child so its bar still draws."""
CORE.dashboard = True
proc = MagicMock()
proc.wait.return_value = 0
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "Popen", return_value=proc) as mock_popen,
):
pf.prefetch_platformio_packages()
assert mock_popen.call_args[1]["env"]["ESPHOME_PREFETCH_DASHBOARD"] == "1"
@pytest.mark.parametrize(
("wait_effect", "spawn_error", "expected"),
[
("timeout", None, "prefetch timed out"),
(4, None, "prefetch skipped (exit 4)"),
# Exit 1 is the interpreter's own import-failure code, never quiet
(1, None, "prefetch skipped (exit 1)"),
(None, OSError("no exec"), "PlatformIO package prefetch skipped"),
],
)
def test_prefetch_spawn_failures_warn_and_continue(
caplog: pytest.LogCaptureFixture, wait_effect, spawn_error, expected
) -> None:
"""Timeouts, nonzero exits, and spawn failures each warn, never raise;
a timed-out child is stopped gracefully. The mock is built per test:
a collection-time mock's consumable side_effect breaks reruns."""
if spawn_error is not None:
popen_effect = {"side_effect": spawn_error}
elif wait_effect == "timeout":
popen_effect = {
"return_value": _proc(
pf.subprocess.TimeoutExpired("cmd", pf._PREFETCH_TIMEOUT)
)
}
else:
popen_effect = {"return_value": _proc(wait_effect)}
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "Popen", **popen_effect),
):
pf.prefetch_platformio_packages()
assert expected in caplog.text
def test_stop_child_waits_terminates_then_kills() -> None:
"""The stop sequence waits for a self-unwinding child first, then
SIGTERMs, and kills only a child that will not stop. The platform is
pinned: on Windows the terminate arm is deliberately skipped."""
timeout = pf.subprocess.TimeoutExpired("cmd", 5)
with patch.object(pf.sys, "platform", "linux"):
# Child already unwinding from its own SIGINT: no signals at all
proc = MagicMock()
proc.wait.return_value = 0
pf._stop_child(proc)
proc.terminate.assert_not_called()
# Child needs the SIGTERM unwind
proc = MagicMock()
proc.wait.side_effect = [timeout, 0]
pf._stop_child(proc)
proc.terminate.assert_called_once_with()
proc.kill.assert_not_called()
# Child ignoring SIGTERM is killed with a bounded reap
proc = MagicMock()
proc.wait.side_effect = [timeout, timeout, 0]
pf._stop_child(proc)
proc.kill.assert_called_once_with()
# An interrupt mid-stop re-raises so the build aborts
proc = MagicMock()
proc.wait.side_effect = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
pf._stop_child(proc)
def test_preinstall_failure_removes_torn_destination(tmp_path: Path) -> None:
"""A failed install removes whatever get_package can see so pio run
genuinely reinstalls it; a cleanup failure warns."""
m = _fake_manager(tmp_path)
m._install.side_effect = RuntimeError("postinstall failed")
# cleanup lookup first, then the dependency-wave lookup
m.get_package.side_effect = [SimpleNamespace(path=str(tmp_path / "torn")), None]
removed: list[str] = []
with patch.object(pf, "rmtree", side_effect=removed.append):
pf._preinstall(m, [("bad@1", _FakeSpec(name="bad"))])
assert removed == [str(tmp_path / "torn")]
def test_preinstall_system_exit_still_cleans(tmp_path: Path) -> None:
"""A worker SystemExit runs the torn cleanup before propagating."""
m = _fake_manager(tmp_path)
m._install.side_effect = SystemExit(143)
m.get_package.side_effect = [SimpleNamespace(path=str(tmp_path / "torn")), None]
removed: list[str] = []
with (
patch.object(pf, "rmtree", side_effect=removed.append),
pytest.raises(SystemExit),
):
pf._preinstall(m, [("bad@1", _FakeSpec(name="bad"))])
assert removed == [str(tmp_path / "torn")]
def test_preinstall_stuck_tree_drops_metadata(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unremovable torn tree loses its .piopm so pio run reinstalls
it instead of trusting it forever."""
m = _fake_manager(tmp_path)
m._install.side_effect = RuntimeError("boom")
torn = tmp_path / "torn"
torn.mkdir()
(torn / ".piopm").write_text("{}")
m.get_package.side_effect = [SimpleNamespace(path=str(torn)), None]
with patch.object(pf, "rmtree", side_effect=OSError("busy")):
pf._preinstall(m, [("bad@1", _FakeSpec(name="bad"))])
assert not (torn / ".piopm").exists()
assert torn.exists() # tidiness is best-effort; metadata is the invariant
def test_preinstall_cleanup_failure_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
m = _fake_manager(tmp_path)
m._install.side_effect = RuntimeError("boom")
m.get_package.side_effect = [OSError("scan failed"), None]
pf._preinstall(m, [("bad@1", _FakeSpec(name="bad"))])
assert "Could not remove the failed install of bad@1" in caplog.text
def test_dependency_entries_honor_compatibility(tmp_path: Path) -> None:
"""A dependency pio's install_dependency would skip as incompatible is
not pre-installed either."""
m = _fake_manager(tmp_path)
m.compatibility = PackageCompatibility(platforms=["espressif32"])
# only the top-level entry is installed; the deps are not
m.get_package.side_effect = lambda spec: (
SimpleNamespace(spec=spec) if getattr(spec, "name", "") == "top" else None
)
m.get_pkg_dependencies.return_value = [
{"owner": "o", "name": "espdep", "version": "^1", "platforms": ["espressif32"]},
{"owner": "o", "name": "avrdep", "version": "^1", "platforms": ["atmelavr"]},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
entries = pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], set())
assert [name for name, *_ in entries] == ["espdep"]
def test_dependency_entries_skip_builtin_libs(tmp_path: Path) -> None:
"""An owner-less versioned dep naming a framework builtin (the dict
manifest form of SPI/Wire) is skipped like pio's install_dependency;
a registry copy would shadow the bundled library."""
m = _fake_manager(tmp_path)
m.is_builtin_lib.side_effect = lambda name: name == "SPI"
m.get_package.side_effect = lambda spec: (
SimpleNamespace(spec=spec) if getattr(spec, "name", "") == "top" else None
)
m.get_pkg_dependencies.return_value = [
{"name": "SPI", "version": "*"},
{"name": "realdep", "version": "^1"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
entries = pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], set())
assert [name for name, *_ in entries] == ["realdep"]
def test_prefetch_interrupt_stops_child_gracefully() -> None:
"""On Ctrl-C the stop sequence waits first; a child that exits on its
own is never signalled, and the interrupt re-raises."""
proc = MagicMock()
proc.wait.side_effect = [KeyboardInterrupt(), 0]
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "Popen", return_value=proc),
pytest.raises(KeyboardInterrupt),
):
pf.prefetch_platformio_packages()
# the stop sequence's first wait saw the child exit on its own
proc.terminate.assert_not_called()
proc.kill.assert_not_called()
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."""
proc = MagicMock()
proc.wait.return_value = pf._EXIT_HANDLED
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "Popen", return_value=proc),
):
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, **kwargs):
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])),
),
PackageCompatibility=SimpleNamespace,
),
"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,
patch.object(pf, "_preinstall") as mock_install,
):
pf._prefetch(tmp_path, "testenv")
mock_batch.assert_not_called()
mock_install.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, "Popen") as mock_popen,
):
pf.prefetch_platformio_packages()
mock_popen.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",
"framework": "arduino",
# 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,
[("toolchain-x@1", specs[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,
patch.object(pf, "_preinstall") as mock_install,
):
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
# every installable failed its download; nothing to pre-install
mock_install.assert_not_called()
def test_prefetch_installs_cached_archives_without_downloads(
tmp_path: Path,
) -> None:
"""Archives already in the download cache still pre-install (in
parallel) even when there is nothing to download, and no sentinel is
written until everything is installed."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {}
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
spec = _FakeSpec(name="cachedpkg")
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=[([], 0, [("cachedpkg@1", spec)]), ([], 0, [])],
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
patch.object(pf, "run_batch_downloads") as mock_batch,
patch.object(pf, "_preinstall") as mock_install,
):
pf._prefetch(tmp_path, "testenv")
mock_batch.assert_not_called()
assert mock_install.call_count == 1
assert mock_install.call_args[0][1] == [("cachedpkg@1", spec)]
assert not (tmp_path / pf._SENTINEL_NAME).exists()
def test_preinstall_extracts_in_parallel_under_one_lock(tmp_path: Path) -> None:
"""The manager lock wraps the whole batch; per-thread managers share
its package dir; one failing install leaves the rest alone."""
m = _fake_manager(tmp_path)
installed: list[str] = []
def fake_install(spec, skip_dependencies, compatibility=None):
# Dependencies must be skipped: a shared dep extracted from two
# threads would race one destination dir
assert skip_dependencies is True
if spec.name == "bad":
raise RuntimeError("corrupt archive")
installed.append(spec.name)
m._install.side_effect = fake_install
entries = [
("a@1", _FakeSpec(name="a")),
("bad@1", _FakeSpec(name="bad")),
("b@1", _FakeSpec(name="b")),
]
pf._preinstall(m, entries)
assert sorted(installed) == ["a", "b"]
m.lock.assert_called_once_with()
m.unlock.assert_called_once_with()
assert m.memcache_reset.call_count >= 1
def test_preinstall_all_failed_warns_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Every install failing is a systemic fault, not archive noise."""
m = _fake_manager(tmp_path)
m._install.side_effect = AttributeError("_install went away")
pf._preinstall(
m,
[
("a@1", _FakeSpec(name="a")),
("b@1", _FakeSpec(name="b")),
],
)
assert "Could not pre-install a@1" in caplog.text
assert "Could not pre-install any of 2" in caplog.text
def test_preinstall_dedupes_names_across_entries(tmp_path: Path) -> None:
"""Two entries with one name install once (one destination dir)."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {}
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
s1 = _FakeSpec(name="dup")
s2 = _FakeSpec(name="dup")
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=[([], 0, [("pkg@1", s1), ("pkg@1", s2)]), ([], 0, [])],
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
patch.object(pf, "_preinstall") as mock_install,
):
pf._prefetch(tmp_path, "testenv")
mock_install.assert_called_once()
(entry,) = mock_install.call_args[0][1]
assert entry[0] == "pkg@1"
assert entry[1] is s2 # the dict comprehension keeps the last duplicate
def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
"""Dependencies of installed packages install in a follow-up wave,
deduped by name; name-only platform libs stay with pio run."""
m = _fake_manager(tmp_path)
installed: list[str] = []
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(spec.name if hasattr(spec, "name") else str(spec))
)
pkg = SimpleNamespace(spec="noise-c")
m.get_package.side_effect = lambda spec: (
pkg if getattr(spec, "name", None) == "noise-c" else None
)
m.get_pkg_dependencies.return_value = [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1]
assert dep_call.kwargs["compatibility"] is not None
def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
"""A dependency whose name matches an already-waved entry is not
reinstalled."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: SimpleNamespace(spec=spec)
m.get_pkg_dependencies.return_value = [
{"owner": "esphome", "name": "noise-c", "version": "^0.1"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
installed: list[str] = []
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec)))
)
pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"]
def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None:
"""Each worker thread gets its own pre-built manager and installs
genuinely overlap (the barrier deadlocks a serial pool). The worker
count is pinned so a 1-CPU host cannot serialize the pool."""
barrier = threading.Barrier(2, timeout=5)
used: set = set()
class _WaveManager:
package_dir = str(tmp_path)
compatibility = None
def __init__(self, package_dir, **kwargs) -> None:
assert package_dir == str(tmp_path)
def lock(self) -> None:
pass
def unlock(self) -> None:
pass
def memcache_reset(self) -> None:
pass
def get_tmp_dir(self) -> str:
return str(tmp_path)
def get_download_dir(self) -> str:
return str(tmp_path)
def get_package(self, spec):
return None
def get_pkg_dependencies(self, pkg):
return None
def _install(self, spec, skip_dependencies, compatibility=None) -> None:
used.add(id(self))
barrier.wait()
seed = _WaveManager(str(tmp_path))
with patch.object(pf, "get_usable_cpu_count", return_value=2):
pf._preinstall(
seed,
[
("a@1", _FakeSpec(name="a")),
("b@1", _FakeSpec(name="b")),
],
)
assert len(used) == 2
assert id(seed) not in used
def test_sibling_manager_and_sigterm() -> None:
"""Sibling managers inherit compatibility; SIGTERM raises SystemExit."""
calls = []
m = MagicMock(package_dir="p", compatibility="qual")
m.__class__ = lambda package_dir, **kw: calls.append((package_dir, kw))
pf._sibling_manager(m)
m.compatibility = None
pf._sibling_manager(m)
assert calls == [("p", {"compatibility": "qual"}), ("p", {})]
with pytest.raises(SystemExit):
pf._sigterm(15, None)
def test_dependency_entries_skip_installed(tmp_path: Path) -> None:
"""A dependency a previous build installed stays off the destructive
failure path."""
m = _fake_manager(tmp_path)
m.get_package.side_effect = lambda spec: SimpleNamespace(spec=spec)
m.get_pkg_dependencies.return_value = [
{"owner": "o", "name": "already", "version": "^1"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
assert pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], set()) == []
def test_group_failure_does_not_skip_other_groups(tmp_path: Path) -> None:
"""One group's pre-install failure degrades that group only."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {}
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
s1 = _FakeSpec(name="toolpkg")
s2 = _FakeSpec(name="libpkg")
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=[
([], 0, [("toolpkg@1", s1)]),
([], 0, [("libpkg@1", s2)]),
],
),
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
patch.object(
pf, "_preinstall", side_effect=[RuntimeError("group down"), None]
) as mock_install,
):
pf._prefetch(tmp_path, "testenv")
assert mock_install.call_count == 2
def test_preinstall_unlocks_even_when_pool_fails(tmp_path: Path) -> None:
"""A failure inside the pool cancels queued installs and releases the
lock; a failing executor construction still releases it."""
m = _fake_manager(tmp_path)
boom = MagicMock()
boom.__enter__.return_value = boom
boom.map.side_effect = RuntimeError("no threads")
with (
patch.object(pf, "ThreadPoolExecutor", return_value=boom),
pytest.raises(RuntimeError),
):
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
m.unlock.assert_called_once_with()
assert boom.shutdown.call_args_list[0][1].get("cancel_futures") is True
m.reset_mock()
# A failing executor construction still releases the lock
with (
patch.object(pf, "ThreadPoolExecutor", side_effect=RuntimeError("no")),
pytest.raises(RuntimeError),
):
pf._preinstall(m, [("a@1", _FakeSpec(name="a"))])
m.unlock.assert_called_once_with()
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"]
def test_platformio_private_api_contract() -> None:
"""The pinned PlatformIO still exposes what the pre-install drives.
Also load-bearing but unpinnable by introspection: pio's private
_install must never re-acquire the manager's inter-process lock
(locking lives in the public install()); a re-lock would hang the
child for the full prefetch timeout, so re-check it on any bump.
Everything else in this module mocks the managers, so this is the one
test that fails loudly when a requirements bump changes the private
surface instead of silently degrading the prefetch to a no-op.
"""
params = inspect.signature(PackageManagerInstallMixin._install).parameters
assert "spec" in params
assert "skip_dependencies" in params
assert "compatibility" in params
for cls in (ToolPackageManager, LibraryPackageManager, PlatformPackageManager):
assert "package_dir" in inspect.signature(cls.__init__).parameters
for name in (
"lock",
"unlock",
"memcache_reset",
"get_package",
"compute_download_path",
"get_pkg_dependencies",
"dependency_to_spec",
):
assert callable(getattr(BasePackageManager, name))
# The dependency wave mirrors install_dependency's builtin skip
assert callable(LibraryPackageManager.is_builtin_lib)
# The pre-install passes these positionally / by keyword
assert "compatibility" in inspect.signature(BasePackageManager.__init__).parameters
lib_params = inspect.signature(LibraryPackageManager.__init__).parameters
# Capability, not implementation: an explicit compatibility= parameter
# would serve the call site just as well as **kwargs forwarding
assert "compatibility" in lib_params or any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in lib_params.values()
)
assert callable(PackageCompatibility.from_dependency)
assert callable(PackageCompatibility.is_compatible)
# Every URL spec derives a name from the URI; only a custom name
# (Foo=https://...) is also the destination dir the wave installs into
derived = PackageSpec("https://x/y/archive/master.zip")
assert derived.name and not derived.has_custom_name()
assert PackageSpec("Foo=https://x/y/archive/master.zip").has_custom_name()