"""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()