mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
986 lines
38 KiB
Python
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"]
|