Files
esphome/tests/unit_tests/test_platformio_prefetch.py
T

986 lines
38 KiB
Python

"""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"]