"""Tests for the shared ccache policy in esphome.build_helpers.ccache.""" from __future__ import annotations import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.build_helpers import ccache from esphome.core import CORE def test_resolve_opt_out() -> None: with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}): assert ccache.resolve_ccache_path() is None def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None: with ( patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value=None), ): assert ccache.resolve_ccache_path() is None assert "no ccache binary" not in caplog.text def test_resolve_probe_failure() -> None: with ( patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value="/usr/bin/ccache"), patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")), ): assert ccache.resolve_ccache_path() is None def test_resolve_explicit_skips_probe_and_warns_missing( caplog: pytest.LogCaptureFixture, ) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), patch("shutil.which", return_value="/usr/bin/ccache"), patch.object(ccache, "_ccache_runs", side_effect=AssertionError), ): assert ccache.resolve_ccache_path() == "/usr/bin/ccache" with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), patch("shutil.which", return_value=None), ): assert ccache.resolve_ccache_path() is None assert "no ccache binary is on PATH" in caplog.text def test_probe_spawns_with_close_fds_false() -> None: with patch("esphome.framework_helpers.subprocess.run") as mock_run: assert ccache._ccache_runs("/usr/bin/ccache") is True assert mock_run.call_args.kwargs["close_fds"] is False def test_defaults_env(tmp_path: Path) -> None: with ( patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")), patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True), ): env = ccache.ccache_defaults_env(tmp_path / "cache") assert env["CCACHE_DIR"] == str(tmp_path / "cache") assert env["CCACHE_DEPEND"] == "1" assert "CCACHE_NOHASHDIR" not in env # user value respected def test_defaults_env_requires_build_path() -> None: with ( patch("esphome.core.CORE", SimpleNamespace(build_path=None)), pytest.raises(ValueError, match="build_path"), ): ccache.ccache_defaults_env(Path("/x")) @pytest.mark.parametrize("value", ["no", "off", "false", "0"]) def test_resolve_opt_out_synonyms(value: str) -> None: """Every recognized falsy spelling disables ccache.""" with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}): assert ccache.resolve_ccache_path() is None def test_resolve_unrecognized_value_warns_and_probes( caplog: pytest.LogCaptureFixture, ) -> None: """An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not silently enable ccache or skip the runnability probe.""" with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}), patch("shutil.which", return_value="/usr/bin/ccache"), patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe, ): assert ccache.resolve_ccache_path() is None mock_probe.assert_called_once() assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text @pytest.mark.parametrize( ("raw", "expected"), [ ("1", True), ("enable", True), ("ON", True), ("0", False), ("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( monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None ) -> None: """cv.boolean's spelling tables plus the 1/0 env convention.""" monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw) assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None: CORE.build_path = tmp_path # Drive-qualified on Windows: "/custom/base" is not absolute there base = "C:\\custom\\base" if os.name == "nt" else "/custom/base" with patch.dict(os.environ, {"CCACHE_BASEDIR": base}, clear=True): assert ccache.effective_ccache_basedir() == base with patch.dict(os.environ, {}, clear=True): assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) # Degenerate values would strip substrings ccache never rewrites for bad in ("", "/", "a/b"): with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True): assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) def test_resolve_absolute_ccache_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Anchor the path: ninja runs from the build directory.""" monkeypatch.chdir(tmp_path) with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"): resolved = ccache.resolve_absolute_ccache_path() assert Path(resolved) == tmp_path / "bin" / "ccache" with patch.object(ccache, "resolve_ccache_path", return_value=None): assert ccache.resolve_absolute_ccache_path() is None def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from esphome.core import CORE CORE.build_path = tmp_path / "build" spec = ("ESPHOME_TEST_PREFIX", "test") monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache")) monkeypatch.setenv("CCACHE_NOHASHDIR", "false") for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"): monkeypatch.delenv(key, raising=False) # None means resolved and disabled assert ccache.ccache_env(None, spec) == {} env = ccache.ccache_env("/usr/bin/ccache", spec) # User-set values are respected; the rest get defaults assert "CCACHE_NOHASHDIR" not in env assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) def test_ccache_defaults_env_escapes_a_dollar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A literal $ is doubled, since ccache expands $VAR in its settings.""" from esphome.core import CORE CORE.build_path = tmp_path / "a$b" for key in ("CCACHE_DIR", "CCACHE_BASEDIR"): monkeypatch.delenv(key, raising=False) env = ccache.ccache_defaults_env(tmp_path / "c$d") assert env["CCACHE_DIR"].endswith("c$$d") assert env["CCACHE_BASEDIR"].endswith("a$$b") def _pch_ccache_env(tmp_path: Path, environ: dict[str, str]) -> dict[str, str]: CORE.build_path = tmp_path / "build" spec = ("ESPHOME_TEST_PREFIX", "test") environ = {"ESPHOME_TEST_PREFIX": str(tmp_path / "cache"), **environ} with patch.dict(os.environ, environ, clear=True): return ccache.ccache_env("/usr/bin/ccache", spec) def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None: """A native build exports the ccache settings the pch needs.""" env = _pch_ccache_env(tmp_path, {}) assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" assert env["CCACHE_PCH_EXTSUM"] == "true"