diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index aa6bf8127b..31e1fb476e 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -15,6 +15,7 @@ regardless of which toolchain consumes the result. from collections import deque from collections.abc import Callable, Iterable from concurrent.futures import ThreadPoolExecutor +import contextlib from dataclasses import dataclass, field import glob import hashlib @@ -103,14 +104,7 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download( - self, - dir_suffix: str, - force: bool = False, - salt: str = "", - namespace: str = "", - progress: Callable[[int], None] | None = None, - ) -> Path: + def _cache_dir(self, dir_suffix: str, salt: str, namespace: str) -> Path: # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so # the build files each backend writes into the library dir can't collide. base_dir = Path(CORE.data_dir) / DOMAIN @@ -120,7 +114,23 @@ class URLSource(Source): h.update(self.url.encode()) if salt: h.update(salt.encode()) - path = base_dir / h.hexdigest()[:8] / dir_suffix + return base_dir / h.hexdigest()[:8] / dir_suffix + + def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool: + """Whether a completed extraction already exists for this source.""" + return ( + self._cache_dir(dir_suffix, salt, namespace) / ".esphome_extracted" + ).is_file() + + def download( + self, + dir_suffix: str, + force: bool = False, + salt: str = "", + namespace: str = "", + progress: Callable[[int], None] | None = None, + ) -> Path: + path = self._cache_dir(dir_suffix, salt, namespace) # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted # extraction is correctly detected and re-run on the next invocation, @@ -918,6 +928,13 @@ def _prefetch_wave( if component.source.url in seen: continue seen.add(component.source.url) + with contextlib.suppress(Exception): + if component.source.is_cached( + component.get_sanitized_name(), salt=salt, namespace=namespace + ): + # A completed extraction downloads nothing; a warm build + # must stay silent + continue components.append(component) if len(components) < 2: return diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 0d293ac0ff..5185dbd9c1 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -644,6 +644,50 @@ def test_content_lengths_head_requests(monkeypatch: pytest.MonkeyPatch) -> None: assert lib._content_lengths(["https://x/a", "https://x/bad"]) == [123, 0] +def test_prefetch_wave_cache_probe_failure_still_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The cache probe is best-effort; a failing probe prefetches anyway.""" + calls: list[str] = [] + monkeypatch.setattr( + ConvertedLibrary, + "download", + lambda self, **kw: calls.append(self.source.url), + ) + monkeypatch.setattr( + URLSource, + "is_cached", + lambda self, *a, **kw: (_ for _ in ()).throw(RuntimeError("no core")), + ) + wave = [ + ("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz"))), + ("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz"))), + ] + lib._prefetch_wave(wave, "", "idf") + assert sorted(calls) == ["https://x/a.tar.gz", "https://x/b.tar.gz"] + + +def test_prefetch_wave_warm_cache_is_silent( + setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Already-extracted archives download nothing; a warm build must not + print a Downloading line or draw a bar.""" + monkeypatch.setattr( + ConvertedLibrary, + "download", + lambda self, **kw: (_ for _ in ()).throw(AssertionError("downloaded")), + ) + wave = [] + for name in ("a", "b", "c"): + comp = ConvertedLibrary(name, "1.0", URLSource(f"https://x/{name}.tar.gz")) + marker_dir = comp.source._cache_dir(comp.get_sanitized_name(), "", "idf") + marker_dir.mkdir(parents=True) + (marker_dir / ".esphome_extracted").touch() + wave.append((name, comp)) + lib._prefetch_wave(wave, "", "idf") + assert "Downloading" not in caplog.text + + def test_prefetch_wave_single_archive_skips_the_pool( monkeypatch: pytest.MonkeyPatch, ) -> None: