Merge branch 'esp8266-native-build-spec' into esp8266-native-ninja-emission

# Conflicts:
#	esphome/build_gen/arduino8266.py
#	tests/unit_tests/build_gen/test_arduino8266.py
This commit is contained in:
J. Nick Koston
2026-08-20 16:07:57 -05:00
15 changed files with 228 additions and 153 deletions
+3 -6
View File
@@ -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__)
+98
View File
@@ -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}
+22
View File
@@ -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()
+18 -19
View File
@@ -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",
+16 -9
View File
@@ -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()
+1 -1
View File
@@ -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__)
+3 -3
View File
@@ -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")
-99
View File
@@ -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}
+12 -4
View File
@@ -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.
+2 -1
View File
@@ -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,
@@ -442,7 +442,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
(paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
"MEMORY\n{\n"
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
" iram1_0_seg : org = 0x40100000, len = 0x8000\n"
" irom0_0_seg : org = 0x40201010, len = 0xfeff0\n"
"}\n"
)
@@ -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
@@ -48,3 +48,25 @@ def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_escape_ninja_specials() -> None:
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
def test_quote_arg_windows_argv_rule() -> None:
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
def test_shell_token_quotes_only_when_needed() -> None:
assert ninja_helper.shell_token("-Os") == "-Os"
assert ninja_helper.shell_token("-DX=$HOME") == "-DX=$$HOME"
assert ninja_helper.shell_token("-DP=C:\\x y") == '"-DP=C:\\x y"'
assert ninja_helper.shell_token("plain", force=True) == '"plain"'
def test_quote_path_force_quotes() -> None:
assert ninja_helper.quote_path(Path("a b")) == '"a b"'
assert ninja_helper.quote_path("simple") == '"simple"'
@@ -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",))
@@ -103,7 +103,7 @@ def test_ccache_path_explicit_skips_probe(monkeypatch: pytest.MonkeyPatch) -> No
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", "1")
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("esphome.framework_helpers._ccache_runs", side_effect=AssertionError),
patch("esphome.build_helpers.ccache._ccache_runs", side_effect=AssertionError),
):
assert framework.ccache_path() == "/usr/bin/ccache"