diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 475e61b6c0..da10502bc2 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -24,14 +24,11 @@ import os from pathlib import Path from typing import NamedTuple +from esphome.build_helpers.ccache import ccache_defaults_env, resolve_ccache_path from esphome.build_helpers.ninja import find_ninja +from esphome.build_helpers.tools_cache import tools_cache_path from esphome.core import EsphomeError, Version -from esphome.framework_helpers import ( - ccache_defaults_env, - resolve_ccache_path, - str_to_lst_of_str, - tools_cache_path, -) +from esphome.framework_helpers import str_to_lst_of_str from esphome.platformio.registry import install_package _LOGGER = logging.getLogger(__name__) diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py new file mode 100644 index 0000000000..11d40ac3c6 --- /dev/null +++ b/esphome/build_helpers/ccache.py @@ -0,0 +1,98 @@ +"""Shared ccache policy for build backends. + +One place for the probe, the enable/override rules, and the ``CCACHE_*`` +defaults, so the backends cannot drift apart. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import subprocess + +from esphome.framework_helpers import strip_win_long_path_prefix + +_LOGGER = logging.getLogger(__name__) + + +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + close_fds=False, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + +def resolve_ccache_path() -> str | None: + """The ccache binary to wrap compiles with, or None when disabled. + + Shared policy for every backend: on by default when a runnable ccache is + on PATH, ``ESPHOME_CCACHE_ENABLE=0`` opts out, and an explicit ``=1`` + warns when no binary is found and skips the runnability probe. The + Windows extended-length prefix is stripped before probing so the probe + validates the exact string the build will execute (#18399). + """ + import shutil + + from esphome.helpers import get_bool_env + + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return None + ccache = shutil.which("ccache") + if ccache is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return None + ccache = strip_win_long_path_prefix(ccache) + if not explicit and not _ccache_runs(ccache): + return None + return ccache + + +def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: + """Default ``CCACHE_*`` values for a build subprocess (not os.environ). + + Values the user already set in the environment are respected. Depend + mode is on: both native backends emit depfiles (-MMD / CMake), which + keeps cache-miss overhead low. + """ + from esphome.core import CORE + + # build_path is set during preload for every config-loading command; unset + # means the caller built the environment too early. Fail loudly rather + # than silently drop CCACHE_BASEDIR (losing cross-device cache hits). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the build environment" + ) + defaults = { + "CCACHE_DIR": str(cache_dir), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + return {k: v for k, v in defaults.items() if k not in os.environ} diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py new file mode 100644 index 0000000000..1960db458f --- /dev/null +++ b/esphome/build_helpers/tools_cache.py @@ -0,0 +1,22 @@ +"""Machine-global tools cache location shared by the native backends.""" + +from __future__ import annotations + +from pathlib import Path + + +def tools_cache_path(env_var: str, subdir: str) -> Path: + """A backend's machine-global tools directory, with an env override. + + A blank/whitespace override is treated as unset: ``Path("")`` resolves + to the CWD, which ``clean-all`` would then delete. + """ + import platformdirs + + from esphome.helpers import get_str_env + + if prefix := get_str_env(env_var, "").strip(): + return Path(prefix).expanduser().resolve() + return ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir + ).resolve() diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 2646682766..4f137e95cd 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -361,31 +361,30 @@ BOARDS = { }, } -""" -ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the -native toolchain mirrors; regenerate against the tag when bumping it): - -git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 -python3 - <<'EOF' -import json, glob, os -for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): - b = json.load(open(f))["build"] - extra = b["extra_flags"] - extra = extra.split() if isinstance(extra, str) else extra - defines = [ - e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") - ] - entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") - board = os.path.splitext(os.path.basename(f))[0] - print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') -EOF -""" # Per-board Arduino core build metadata for the native (PlatformIO-free) # toolchain: the variant directory (supplies pins_arduino.h) and the # board-identity defines the PlatformIO builder passes via build.extra_flags. # -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by # the generator; only the per-board defines are listed here. +# +# ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF ESP8266_BOARD_BUILD = { "agruminolemon": { "variant": "agruminolemonv4", diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 47f9042f08..97ce750dd5 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -82,6 +82,14 @@ def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str raises, since a silently kept real memory limit would fail grouped builds far from the cause. """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + # A known segment left unpatched would keep its real memory limit + # and silently under-provision the testing build + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) for segment in segments: if segment not in _TESTING_SEGMENT_SIZES: raise RuntimeError(f"Unknown testing-mode segment {segment!r}") @@ -103,15 +111,14 @@ def segment_length(content: str, segment_name: str) -> int | None: def surgery_fingerprint() -> str: - """Fingerprint of every behavioral input to the surgeries. + """Fingerprint of this module's source, covering every behavioral input. - Linker-script caches include it so an edit here invalidates them. + Linker-script caches include it so an edit here invalidates them; hashing + the source over-invalidates on comment edits, which is the safe direction. Native-toolchain-only, like ``segment_length``; no script twin. """ - parts = ( - RATETABLE_RULE, - _RATETABLE_COMMENT, - _RATETABLE_ANCHOR.pattern, - repr(sorted(_TESTING_SEGMENT_SIZES.items())), - ) - return hashlib.sha256("|".join(parts).encode()).hexdigest() + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 48963fa89f..578e91fe40 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -7,6 +7,7 @@ import shutil import sys import tempfile +from esphome.build_helpers.tools_cache import tools_cache_path import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -18,7 +19,6 @@ from esphome.framework_helpers import ( rmdir, run_command_ok, str_to_lst_of_str, - tools_cache_path, ) _LOGGER = logging.getLogger(__name__) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b4b03ce113..773fccdf18 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -11,11 +11,12 @@ import re import shutil from typing import Any, NoReturn +from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.tools_cache import tools_cache_path from esphome.core import Version from esphome.framework_helpers import ( PathType, archive_extract_all, - ccache_defaults_env, create_venv, download_from_mirrors, download_with_resume, @@ -25,7 +26,6 @@ from esphome.framework_helpers import ( run_command, run_command_ok, str_to_lst_of_str, - tools_cache_path, ) from esphome.helpers import get_bool_env, write_file_if_changed @@ -89,7 +89,7 @@ def get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ # Machine-global so all projects share the multi-GB install instead of - # a per-config-directory copy; see framework_helpers.tools_cache_path + # a per-config-directory copy; see build_helpers.tools_cache.tools_cache_path # for the env-override and normalization rules. return tools_cache_path("ESPHOME_ESP_IDF_PREFIX", "idf") diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 4da53adada..1ceafdf9c2 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -1171,23 +1171,6 @@ def download_from_mirrors( raise ValueError("download_from_mirrors called with an empty mirrors list") -def tools_cache_path(env_var: str, subdir: str) -> Path: - """A backend's machine-global tools directory, with an env override. - - A blank/whitespace override is treated as unset: ``Path("")`` resolves - to the CWD, which ``clean-all`` would then delete. - """ - import platformdirs - - from esphome.helpers import get_str_env - - if prefix := get_str_env(env_var, "").strip(): - return Path(prefix).expanduser().resolve() - return ( - Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir - ).resolve() - - def strip_win_long_path_prefix(path: str) -> str: r"""Strip the Windows extended-length path prefix from ``path``. @@ -1220,85 +1203,3 @@ def strip_win_long_path_prefix(path: str) -> str: if path.startswith("\\\\?\\"): return path[len("\\\\?\\") :] return path - - -def _ccache_runs(ccache: str) -> bool: - """Return True when the ``ccache`` found on PATH actually runs. - - ``shutil.which`` proves existence, not runnability: on Windows it also - matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose - target is gone. Wrapping compiles around such a find fails every compile - step with an opaque OS error, so probe once and fall back to compiling - without ccache when the probe fails. - """ - try: - subprocess.run( - [ccache, "--version"], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=15, - close_fds=False, - ) - except (OSError, subprocess.SubprocessError): - _LOGGER.warning( - "Ignoring ccache at %s because it failed to run; compiling without ccache", - ccache, - ) - return False - return True - - -def resolve_ccache_path() -> str | None: - """The ccache binary to wrap compiles with, or None when disabled. - - Shared policy for every backend: on by default when a runnable ccache is - on PATH, ``ESPHOME_CCACHE_ENABLE=0`` opts out, and an explicit ``=1`` - warns when no binary is found and skips the runnability probe. The - Windows extended-length prefix is stripped before probing so the probe - validates the exact string the build will execute (#18399). - """ - import shutil - - from esphome.helpers import get_bool_env - - explicit = "ESPHOME_CCACHE_ENABLE" in os.environ - if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): - return None - ccache = shutil.which("ccache") - if ccache is None: - if explicit: - _LOGGER.warning( - "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " - "compiling without ccache" - ) - return None - ccache = strip_win_long_path_prefix(ccache) - if not explicit and not _ccache_runs(ccache): - return None - return ccache - - -def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: - """Default ``CCACHE_*`` values for a build subprocess (not os.environ). - - Values the user already set in the environment are respected. Depend - mode is on: both native backends emit depfiles (-MMD / CMake), which - keeps cache-miss overhead low. - """ - from esphome.core import CORE - - # build_path is set during preload for every config-loading command; unset - # means the caller built the environment too early. Fail loudly rather - # than silently drop CCACHE_BASEDIR (losing cross-device cache hits). - if CORE.build_path is None: - raise ValueError( - "CORE.build_path must be set before constructing the build environment" - ) - defaults = { - "CCACHE_DIR": str(cache_dir), - "CCACHE_NOHASHDIR": "true", - "CCACHE_DEPEND": "1", - "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), - } - return {k: v for k, v in defaults.items() if k not in os.environ} diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 726c3e04bb..ef75421671 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -35,7 +35,7 @@ import os from pathlib import Path from typing import TYPE_CHECKING -from esphome.platformio.library import ensure_list +from esphome.core import EsphomeError if TYPE_CHECKING: from esphome.platformio.library import ConvertedLibrary @@ -89,9 +89,17 @@ def apply_extra_script( extra_flags = captured_as_build_flags(result, library_dir=source_path) if not extra_flags: return - flags = ensure_list(component.data.setdefault("build", {}).setdefault("flags", [])) - flags.extend(extra_flags) - component.data["build"]["flags"] = flags + flags = component.data.setdefault("build", {}).setdefault("flags", []) + if isinstance(flags, str): + flags = [flags] + elif not isinstance(flags, list): + # A null/dict value coerced through a list wrapper would inject a + # non-string into the compiler command line; fail naming the library + raise EsphomeError( + f"Library {component.name} has a malformed build.flags " + f"({type(flags).__name__}); expected a string or list" + ) + component.data["build"]["flags"] = [*flags, *extra_flags] # Keys we know how to translate back into ESPHome's build-flag pipeline. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 3eeaecf7e4..cf2094dfe0 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -9,9 +9,10 @@ from typing import TYPE_CHECKING, Any import platformdirs +from esphome.build_helpers.ccache import resolve_ccache_path from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.framework_helpers import resolve_ccache_path, strip_win_long_path_prefix +from esphome.framework_helpers import strip_win_long_path_prefix from esphome.helpers import ( add_git_ceiling_directory, copy_file_if_changed, diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index 6c44192439..51f5b2384c 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -451,3 +451,17 @@ def test_load_or_build_idedata_never_caches_bad_compiler(tmp_path: Path) -> None ) assert data["cxx_path"] == "/usr/bin/python3" assert not cache.exists() + + +def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None: + """A valid cache newer than the compile DB is served without re-parsing.""" + compile_commands = _write_compile_commands(tmp_path) + cache = tmp_path / "c.json" + cache.write_text(json.dumps({"cc_path": "/tools/gcc", "cached": True})) + os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2) + with patch.object(idedata, "idedata_from_build") as mock_build: + data = idedata.load_or_build_idedata( + compile_commands, tmp_path / "f.elf", cache + ) + mock_build.assert_not_called() + assert data["cached"] is True diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index e62a2270fb..944518b1fa 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -49,7 +49,8 @@ def test_relocate_ratetable_inserts_after_data_start() -> None: patched = relocate_ratetable(_COMMON_LD_SNIPPET) assert RATETABLE_RULE in patched # Inserted after the .data section's anchor, not the .dport0.data one - assert patched.index("_data_start = ABSOLUTE(.);") < patched.index(RATETABLE_RULE) + # (whose closing brace bounds the decoy block) + assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")] assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") # Idempotent on an already-patched script assert relocate_ratetable(patched) == patched @@ -106,14 +107,20 @@ def test_board_build_covers_every_board() -> None: assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) -def test_surgery_fingerprint_tracks_inputs() -> None: - """The fingerprint changes with any behavioral input, so linker-script - caches stamped with it self-invalidate on surgery edits.""" - from unittest.mock import patch +def test_surgery_fingerprint_covers_module_source() -> None: + """The fingerprint hashes the module source, so any surgery edit + invalidates linker-script caches stamped with it.""" + import hashlib + import inspect from esphome.components.esp8266 import build_surgery - base = build_surgery.surgery_fingerprint() - assert base == build_surgery.surgery_fingerprint() - with patch.object(build_surgery, "_TESTING_SEGMENT_SIZES", {"iram1_0_seg": "0x1"}): - assert build_surgery.surgery_fingerprint() != base + expected = hashlib.sha256(inspect.getsource(build_surgery).encode()).hexdigest() + assert build_surgery.surgery_fingerprint() == expected + + +def test_testing_memory_patches_present_but_unselected_raises() -> None: + """A known segment left off the caller's list must fail, not silently + keep its real memory limit.""" + with pytest.raises(RuntimeError, match="not selected"): + apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))