diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 82d62615ee..4a54f7b19d 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -36,6 +36,9 @@ def parse_enable_env(name: str) -> bool | None: if raw is None: return None lowered = raw.strip().lower() + if not lowered: + # ENV KNOB= (Docker/CI) has always read as a disable + return False if lowered in TRUTHY_ENV_STRINGS: return True if lowered in FALSY_ENV_STRINGS: diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4be68d5ce6..afb58611c7 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1206,14 +1206,20 @@ def _ccache_env() -> dict[str, str]: # os.environ, where a non-false-constant string reads as truthy; # export the canonical off spelling instead return {"IDF_CCACHE_ENABLE": "0"} - if idf_knob is None and resolve_ccache_path() is None: - # ESP-IDF silently skips ccache without the binary; don't enable it. - return {} + if idf_knob is True: + # Forced on skips the runnability verdict, but still resolve for + # the "no ccache binary on PATH" warning + resolve_ccache_path() + elif resolve_ccache_path() is None: + # ESP-IDF silently skips ccache without the binary; export the + # canonical off spelling so an unparsable inherited value (or a + # probe-rejected ccache idf.py would still find) cannot enable it + return {"IDF_CCACHE_ENABLE": "0"} env = ccache_defaults_env(get_idf_tools_path() / "ccache") - if idf_knob is None: - # An unparsable IDF_CCACHE_ENABLE must not leak to idf.py as truthy - env["IDF_CCACHE_ENABLE"] = "1" + # Exactly one canonical spelling ever reaches idf.py, whatever the + # accepted input spelling was ("enable", "yes", ...) + env["IDF_CCACHE_ENABLE"] = "1" return env diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index eee20958e9..1d056d4478 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -4,7 +4,7 @@ platformio package (identical bits, esphome's own download machinery).""" from __future__ import annotations from collections.abc import Callable, Collection -from functools import partial +from functools import cache, partial import io import json import logging @@ -53,11 +53,14 @@ def get_systype() -> str: return f"{system}_{arch}" if arch else system +@cache def registry_download(package: str, version: str) -> tuple[str, str, int | None]: """Resolve a package's download URL, sha256, and size via the registry. The metadata fetch goes through ``download_from_mirrors`` so it shares the retry, backoff, and error reporting of every other download here. + Cached per process so the prefetch and the install resolve each package + once (failures are not cached; the install retries them). """ buf = io.BytesIO() download_from_mirrors([_REGISTRY_URL], {"package": package}, buf) diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index 619a1a3476..0237db4081 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -109,6 +109,9 @@ def test_resolve_unrecognized_value_warns_and_probes( ("disable", False), ("Off", False), ("maybe", None), + # ENV KNOB= (Docker/CI) has always read as a disable + ("", False), + (" ", False), ], ) def test_parse_enable_env_spelling_tables( diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 6f0dc475d4..b8c3796569 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1564,7 +1564,8 @@ def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: # build_path is None here too: a disabled cache must not require it. p1, p2, p3 = _ccache_patches(tmp_path, None, None) with patch.dict("os.environ", {}, clear=True), p1, p2, p3: - assert _ccache_env() == {} + # Canonical off, so an inherited/unparsable value cannot enable it + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: @@ -1578,12 +1579,12 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: - # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's - # already in the environment, so it isn't re-emitted, but the rest is. + # Explicit IDF_CCACHE_ENABLE=1 forces it on; the probe verdict is + # ignored but the resolver still runs for its no-binary warning. p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: env = _ccache_env() - assert "IDF_CCACHE_ENABLE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") assert env["CCACHE_DEPEND"] == "1" @@ -1595,7 +1596,7 @@ def test_ccache_env_honors_shared_esphome_opt_out(tmp_path: Path) -> None: env_vars = {"ESPHOME_CCACHE_ENABLE": "0", "PATH": "/usr/bin"} with patch.dict("os.environ", env_vars, clear=True), p2, p3: # The real resolver runs so the opt-out parse is exercised - assert _ccache_env() == {} + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} @pytest.mark.parametrize("value", ["off", "no"]) @@ -1627,7 +1628,7 @@ def test_ccache_env_idf_knob_wins_over_shared_opt_out(tmp_path: Path) -> None: with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3: env = _ccache_env() assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") - assert "IDF_CCACHE_ENABLE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 46409ba62e..f969fe825a 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -14,6 +14,44 @@ from esphome.core import EsphomeError from esphome.platformio import registry +def test_registry_download_resolves_once_per_process() -> None: + """The prefetch and the install share one metadata resolve per package.""" + calls: list[dict] = [] + payload = { + "versions": [ + { + "name": "1.0.0", + "files": [ + { + "download_url": "http://x/pkg.tar.gz", + "checksum": {"sha256": "ab" * 32}, + "size": 5, + } + ], + } + ] + } + + def fake_download(mirrors, substitutions, target): + calls.append(substitutions) + target.write(json.dumps(payload).encode()) + return mirrors[0] + + with patch.object(registry, "download_from_mirrors", side_effect=fake_download): + first = registry.registry_download("o/pkg", "1.0.0") + second = registry.registry_download("o/pkg", "1.0.0") + assert first == second + assert len(calls) == 1 + + +@pytest.fixture(autouse=True) +def _fresh_registry_cache(): + # registry_download memoizes per process; tests reuse package names + registry.registry_download.cache_clear() + yield + registry.registry_download.cache_clear() + + @pytest.mark.parametrize( ("system", "machine", "expected"), [