From 6d33b0401df057b09d3f0ad4dbe8cd1ddfd80c10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Oct 2026 13:28:40 -0500 Subject: [PATCH] [nrf52] Use the shared managed ccache settings for the native build (#20027) --- esphome/components/nrf52/__init__.py | 15 ++-- esphome/components/nrf52/clang_tidy.py | 2 +- esphome/components/nrf52/framework.py | 16 ++++- tests/unit_tests/components/nrf52/test_pch.py | 71 ++++++++++++++----- tests/unit_tests/test_nrf52_framework.py | 29 +++++++- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 07507901ba..1e841fffcf 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -9,6 +9,7 @@ import subprocess from esphome import pins from esphome.build_helpers import pch +from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -584,7 +585,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: raise EsphomeError("Not implemented yet") check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(None) # no compile, just nrfutil build_dir = CORE.relative_pioenvs_path(CORE.name) dfu_package = build_dir / "firmware.zip" if not dfu_package.is_file(): @@ -666,7 +667,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if not CORE.using_toolchain_platformio: check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(resolve_ccache_path()) # west flash may rebuild build_dir = CORE.relative_pioenvs_path(CORE.name) west_cmd = [ str(paths["python_executable"]), @@ -949,7 +950,10 @@ def run_compile(args, config: ConfigType) -> bool: check_and_install() paths = get_build_paths() - env = get_build_env() + # Depend mode in the shared ccache settings keeps the .gch sound + # across Kconfig flips. + ccache = resolve_ccache_path() + env = get_build_env(ccache) pch_on = _pch_usable() cmake_lists_changed = _generate_cmake_lists(pch_on) @@ -976,11 +980,6 @@ def run_compile(args, config: ConfigType) -> bool: if pch_on: pch.log_pch_in_use() - # Zephyr turns ccache on by itself when it is installed - env.update(pch.ccache_pch_env()) - # Depend mode, or a Kconfig flip reuses a stale .gch: autoconf.h is - # all #defines, which vanish from the preprocessed creation hash. - env.setdefault("CCACHE_DEPEND", "1") _write_pch_checksum(build_dir, source_dir) west_cmd = _west_build_command( diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 89272cd406..d1f9597976 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -277,7 +277,7 @@ def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: ] if not run_command_ok( west_cmd, - env=get_build_env(), + env=get_build_env(None), # configure only, nothing compiles stream_output=True, cwd=str(paths["framework_path"]), ): diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 58c87619bf..e6ccde6a2d 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -10,6 +10,7 @@ import platform import shutil import sys +from esphome.build_helpers.ccache import ccache_env from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv @@ -194,7 +195,14 @@ def get_build_paths() -> dict: } -def get_build_env() -> dict: +def get_build_env(ccache: str | None) -> dict: + """Build the west/sdk-nrf process environment. + + ``ccache`` is the resolved binary (resolve_ccache_path), or None when + ccache is disabled or the caller never compiles; it brings the shared + managed-ccache settings and the pch sloppiness, so every caller that + may compile gets the same cache. + """ version = _get_version_str() venv_bin_dir = get_python_env_executable_path( _get_python_env_path(version), "python" @@ -211,6 +219,12 @@ def get_build_env() -> dict: # non-root builds failed to locate the SDK with it, while # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(_get_toolchain_version())) + if ccache is None: + # Zephyr wraps compiles with any ccache it finds; unmanaged it + # must not cache (a sysbuild image never sees USE_CCACHE=0). + env.setdefault("CCACHE_DISABLE", "1") + else: + env.update(ccache_env(ccache, SDK_NRF_TOOLS_CACHE)) return env diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py index 04932e76cc..41635c3580 100644 --- a/tests/unit_tests/components/nrf52/test_pch.py +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest from esphome.components import nrf52 +from esphome.components.nrf52 import framework from esphome.components.zephyr.const import KEY_BOARD import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain @@ -105,6 +106,23 @@ def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None: assert not build_dir.exists() +def _fake_build_env(ccache: str | None) -> dict[str, str]: + """The real get_build_env with only the install path lookups stubbed.""" + with ( + patch.object(framework, "_get_version_str", return_value="v1"), + patch.object(framework, "_get_python_env_path", return_value=Path("/penv")), + patch.object( + framework, + "get_python_env_executable_path", + return_value=Path("/penv/bin/python"), + ), + patch.object(framework, "_get_framework_path", return_value=Path("/fw")), + patch.object(framework, "_get_toolchain_version", return_value="t1"), + patch.object(framework, "_get_toolchain_path", return_value=Path("/tc")), + ): + return framework.get_build_env(ccache) + + @pytest.fixture def run_cmd(tmp_path: Path) -> Mock: CORE.config_path = tmp_path / "test.yaml" @@ -113,6 +131,7 @@ def run_cmd(tmp_path: Path) -> Mock: CORE.toolchain = Toolchain.SDK_NRF CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 2, 0)} with ( + patch.dict("os.environ", {}, clear=True), patch.object(nrf52, "check_and_install"), patch.object(nrf52, "_generate_cmake_lists", return_value=False), patch.object( @@ -120,18 +139,18 @@ def run_cmd(tmp_path: Path) -> Mock: "get_build_paths", return_value={"python_executable": "python3", "framework_path": tmp_path}, ), - patch.object(nrf52, "get_build_env", return_value={}), + patch.object(nrf52, "get_build_env", side_effect=_fake_build_env), + patch.object(nrf52, "resolve_ccache_path", return_value="/usr/bin/ccache"), patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), patch.object(nrf52, "run_command_ok", return_value=False) as run, ): yield run -def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: - # clear=True also drops ambient CCACHE_* overrides; the header is on - # explicitly since Windows hosts start with it off +def test_shared_ccache_settings_reach_west(run_cmd: Mock, tmp_path: Path) -> None: + # The header is on explicitly since Windows hosts start with it off with ( - patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True), + patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}), pytest.raises(EsphomeError, match="nRF52 native build failed"), ): nrf52.run_compile(None, {}) @@ -140,23 +159,43 @@ def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" # Without depend mode a Kconfig flip reuses a stale .gch assert env["CCACHE_DEPEND"] == "1" + # The full managed set, not a bespoke subset + assert env["CCACHE_DIR"].endswith("ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + assert "CCACHE_DISABLE" not in env -def test_ccache_depend_respects_a_user_override(run_cmd: Mock) -> None: +def test_user_exported_ccache_values_win(run_cmd: Mock) -> None: + user = {"ESPHOME_PCH_ENABLE": "1", "CCACHE_DEPEND": "0", "CCACHE_DIR": "/mine"} with ( - patch.object(nrf52, "get_build_env", return_value={"CCACHE_DEPEND": "0"}), - patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True), + patch.dict("os.environ", user), pytest.raises(EsphomeError, match="nRF52 native build failed"), ): nrf52.run_compile(None, {}) - assert run_cmd.call_args.kwargs["env"]["CCACHE_DEPEND"] == "0" + env = run_cmd.call_args.kwargs["env"] + assert env["CCACHE_DEPEND"] == "0" + assert env["CCACHE_DIR"] == "/mine" -def test_disabled_leaves_the_west_environment_alone( - run_cmd: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") - with pytest.raises(EsphomeError, match="nRF52 native build failed"): +def test_no_ccache_disables_the_zephyr_launcher(run_cmd: Mock) -> None: + """ESPHOME_CCACHE_ENABLE=0 must also stop Zephyr's self-enabled ccache.""" + with ( + patch.object(nrf52, "resolve_ccache_path", return_value=None), + pytest.raises(EsphomeError, match="nRF52 native build failed"), + ): nrf52.run_compile(None, {}) - assert "CCACHE_PCH_EXTSUM" not in run_cmd.call_args.kwargs["env"] - assert "CCACHE_DEPEND" not in run_cmd.call_args.kwargs["env"] + env = run_cmd.call_args.kwargs["env"] + assert env["CCACHE_DISABLE"] == "1" + assert "CCACHE_DEPEND" not in env + + +def test_disabled_pch_still_gets_the_shared_settings(run_cmd: Mock) -> None: + with ( + patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "0"}), + pytest.raises(EsphomeError, match="nRF52 native build failed"), + ): + nrf52.run_compile(None, {}) + env = run_cmd.call_args.kwargs["env"] + assert "CCACHE_PCH_EXTSUM" not in env + assert env["CCACHE_DEPEND"] == "1" diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 17d583d139..d9cf2d37b3 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1078,8 +1078,9 @@ def test_get_build_env( containerized non-root builds and was removed. """ monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept") + monkeypatch.delenv("CCACHE_DISABLE", raising=False) - env = get_build_env() + env = get_build_env(None) tools = get_sdk_nrf_tools_path() venv_bin_dir = get_python_env_executable_path( @@ -1096,6 +1097,30 @@ def test_get_build_env( assert "Zephyr-sdk_DIR" not in env # The rest of the process environment is inherited assert env["SOME_PREEXISTING_VAR"] == "kept" + # No managed settings without a resolved binary; the self-enabled + # Zephyr ccache must not cache + assert "CCACHE_DIR" not in env or "CCACHE_DIR" in os.environ + assert env["CCACHE_DISABLE"] == "1" + + +def test_get_build_env_with_ccache( + nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A resolved ccache brings the shared managed settings.""" + for key in ( + "CCACHE_DIR", + "CCACHE_DEPEND", + "CCACHE_NOHASHDIR", + "CCACHE_BASEDIR", + "CCACHE_DISABLE", + ): + monkeypatch.delenv(key, raising=False) + CORE.build_path = tmp_path / "build" + env = get_build_env("/usr/bin/ccache") + assert env["CCACHE_DIR"] == str(get_sdk_nrf_tools_path() / "ccache") + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + assert "CCACHE_DISABLE" not in env def test_get_build_env_sdk_3_4_0_uses_toolchain_root( @@ -1104,7 +1129,7 @@ def test_get_build_env_sdk_3_4_0_uses_toolchain_root( """For NCS >= 3.4.0, ZEPHYR_SDK_INSTALL_DIR still points at the toolchain root.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse("3.4.0")} - env = get_build_env() + env = get_build_env(None) tools = get_sdk_nrf_tools_path() assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(tools / "toolchains" / "1.0.1")