Compare commits

..
Author SHA1 Message Date
dependabot[bot] a37790039e Bump cryptography from 48.0.1 to 50.0.0
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 21:22:29 +00:00
39 changed files with 170 additions and 7959 deletions
@@ -1,39 +0,0 @@
name: Cache Arduino ESP8266
description: >
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
seed job saves before any compile runs). Exports
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
the cached path; the Python venv must already be restored. Mirrors
cache-esp-idf: only dev-branch pushes write the shared cache, everything
else restores.
runs:
using: composite
steps:
- name: Resolve the native toolchain cache key
# Versions are pinned in code, not a hashable file; resolve them so a
# bump changes the cache key. Assignment form so errexit catches a
# resolver failure.
id: version
shell: bash
run: |
# One owner for the install prefix: exported here and referenced by
# the cache steps below via env, so the caller's install and the
# cached path cannot diverge.
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
. venv/bin/activate
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
[ -n "$key" ] || exit 1
echo "key=$key" >> "$GITHUB_OUTPUT"
- name: Cache the native toolchain (write on dev)
if: github.ref == 'refs/heads/dev'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
- name: Restore the native toolchain (off dev)
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
+1 -67
View File
@@ -102,8 +102,6 @@ jobs:
device-builder: ${{ steps.determine.outputs.device-builder }}
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -167,8 +165,6 @@ jobs:
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -187,32 +183,6 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
seed-esp8266-native-cache:
name: Seed the esp8266 native toolchain cache
runs-on: ubuntu-24.04
needs:
- common
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
# the shared entry test-esp8266-native restores. Only dev: the composite
# action saves nowhere else, so a beta/release push would download the
# toolchain and discard it.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
timeout-minutes: 15
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Install the native toolchain
run: |
. venv/bin/activate
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
@@ -1258,7 +1228,7 @@ jobs:
# compile validates config first, so a separate config pass is
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
echo ""
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
@@ -1267,40 +1237,6 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
test-esp8266-native:
name: Test esp8266 components with the native toolchain
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
env:
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Run native toolchain compile test
run: |
. venv/bin/activate
echo "Testing components: $TEST_COMPONENTS"
echo ""
# ESP8266 Arduino built directly (no PlatformIO); compile validates
# config first, so a separate config pass is redundant.
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
@@ -1663,7 +1599,6 @@ jobs:
needs:
- common
- seed-apt-cache
- seed-esp8266-native-cache
- determine-jobs
- ci-custom
- pylint
@@ -1679,7 +1614,6 @@ jobs:
- clang-tidy-esp32-variants
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
+31 -83
View File
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
from esphome.build_gen import espidf
espidf.write_project()
elif not CORE.using_native_toolchain:
# Other native builds generate their project at compile time;
# never write a platformio.ini for them
else:
from esphome.build_gen import platformio
platformio.write_project()
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
from esphome.build_helpers.idedata import warn_if_idedata_missing
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
warn_if_idedata_missing(toolchain.get_idedata)
elif CORE.using_native_toolchain:
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
"backend claimed the build"
)
try:
if toolchain.get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
# The firmware already built; an idedata failure must not fail
# a successful build.
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
from esphome.platformio import toolchain
@@ -971,15 +975,12 @@ def upload_using_esptool(
if file is not None:
flash_images = [FlashImage(path=file, offset="0x0")]
elif (native := _native_toolchain_module()) is not None:
# Every native backend supplies its own 0x0 flash image (bootloader
# and partitions included where the target needs them)
image = native.get_factory_firmware_path()
if not image.is_file():
raise EsphomeError(
f"{image} does not exist; compile the configuration first"
)
flash_images = [FlashImage(path=image, offset="0x0")]
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
flash_images = [
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
]
else:
from esphome.platformio import toolchain
@@ -1949,39 +1950,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
return run_multiple_configs(files, build_command)
# Native build backend per (target platform, toolchain). Keyed here rather
# than through a platform hook so the serial upload/logs fast path never
# imports the platform component package (see the esp32 variant comment in
# upload_using_esptool); the platform half comes from CORE.data the same way.
_NATIVE_TOOLCHAIN_MODULES = {
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
}
def _native_toolchain_module():
"""The native build backend module for the resolved toolchain."""
if not CORE.using_native_toolchain:
return None
key = (CORE.target_platform, CORE.toolchain)
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
# Degrading to the PlatformIO path would build with the wrong backend
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
f"module for platform {CORE.target_platform}"
)
return importlib.import_module(module_path)
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
native_toolchain = _native_toolchain_module()
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
if native_toolchain is not None:
# Native toolchains derive idedata from the build's
# compile_commands.json, so the configuration must already be compiled.
idedata = native_toolchain.get_idedata()
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
@@ -2020,17 +1997,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
# Refuse an unsupported toolchain before paying for a full compile
native_toolchain = _native_toolchain_module()
if native_toolchain is None and not CORE.using_toolchain_platformio:
_LOGGER.error(
"analyze-memory is not supported with the '%s' toolchain on %s; "
"re-run with --toolchain platformio",
CORE.toolchain.value if CORE.toolchain else "unresolved",
CORE.target_platform,
)
return 1
# Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config)
if exit_code != 0:
@@ -2042,31 +2008,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
# Get idedata for analysis
idedata = None
if native_toolchain is not None:
objdump = native_toolchain.get_objdump_path()
readelf = native_toolchain.get_readelf_path()
for tool in (objdump, readelf):
if not tool.is_file():
# The analyzer would silently fall back to host binutils,
# which cannot read the target ELF. clean-all is heavy for
# ESP-IDF, so suggest a recompile first.
_LOGGER.error(
"%s is missing; the toolchain install may be incomplete "
"(recompile, or run 'esphome clean-all' if it persists)",
tool,
)
return 1
objdump_path = str(objdump)
readelf_path = str(readelf)
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
firmware_elf = native_toolchain.get_elf_path()
if not firmware_elf.is_file():
# The analyzer swallows tool failures, so a missing ELF would
# produce an exit-0 zeroed report
_LOGGER.error(
"%s is missing; compile the configuration first", firmware_elf
)
return 1
objdump_path = str(toolchain.get_objdump_path())
readelf_path = str(toolchain.get_readelf_path())
firmware_elf = toolchain.get_elf_path()
else:
from esphome.platformio import toolchain
+1 -4
View File
@@ -19,7 +19,6 @@ from typing import NamedTuple
from esphome.build_helpers.ccache import ccache_defaults_env
from esphome.build_helpers.ninja import find_ninja
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import str_to_lst_of_str
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
"""
if ccache is None:
return {}
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
env.update(ccache_pch_env())
return env
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
-329
View File
@@ -1,329 +0,0 @@
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import subprocess
from typing import Any
from esphome.arduino8266 import framework
from esphome.build_helpers.ccache import resolve_ccache_path
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE, EsphomeError
from esphome.helpers import write_file_if_changed
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
_MAX_RAM_SIZE = 81920
def _warn_ignored_platformio_options() -> None:
"""Warn for component-added platformio options the native build drops.
The consumed set is exported by core/config.py next to the routing that
stores these options, so the two cannot drift; YAML upload_speed never
reaches CORE.platformio_options here.
"""
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
for key in sorted(CORE.platformio_options or {}):
if key not in consumed:
_LOGGER.warning(
"platformio_options->%s is ignored when building with the "
"native 'arduino' toolchain",
key,
)
_RAM_SECTIONS = (".data", ".rodata", ".bss")
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
def get_build_dir() -> Path:
return CORE.relative_pioenvs_path(CORE.name)
def get_elf_path() -> Path:
return get_build_dir() / "firmware.elf"
def _toolchain_tool(name: str) -> Path:
return framework.toolchain_tool(framework.get_toolchain_path(), name)
def get_factory_firmware_path() -> Path:
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
8266 factory copy exists for artifact-contract parity, not content)."""
return get_build_dir() / "firmware.factory.bin"
def get_addr2line_path() -> Path:
return _toolchain_tool("addr2line")
def get_objdump_path() -> Path:
return _toolchain_tool("objdump")
def get_readelf_path() -> Path:
return _toolchain_tool("readelf")
def run_compile(config: ConfigType, verbose: bool) -> int:
from esphome.build_gen import arduino8266 as build_gen
_warn_ignored_platformio_options()
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
# Resolved once per build: the resolution probes PATH and spawns the
# runnability check, and three consumers need the same answer
ccache = resolve_ccache_path()
ninja_changed = build_gen.write_project(paths, ccache)
build_dir = get_build_dir()
env = framework.get_build_env(paths.toolchain, ccache)
# Regenerate the compile DB before the build (a pure function of
# build.ninja); skip only when it is at least as fresh as build.ninja
# (an interrupted previous run may have rewritten the manifest without
# regenerating the DB).
compdb = build_dir / "compile_commands.json"
compdb_stamp = build_dir / ".compile_commands.stamp"
ninja_file = build_dir / "build.ninja"
# Freshness rides a stamp: the DB itself is written through
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
# regeneration with identical content would stay "stale" forever
if (
ninja_changed
or not compdb.is_file()
or not compdb_stamp.is_file()
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
):
_write_compile_commands(paths.ninja, build_dir, env)
compdb_stamp.touch()
cmd = [str(paths.ninja)]
if verbose:
cmd.append("-v")
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
cmd += ["-j", str(jobs)]
# Explicit targets, not the default statement: a generator defect that
# drops them fails loudly with "unknown target" instead of a green
# no-op run that leaves stale artifacts in place
targets = ["firmware.factory.bin", "firmware.ota.bin"]
cmd += targets
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
# "no work to do". A freshly rewritten manifest all but guarantees work,
# so skip the probe (and its full stat pass) on that path. cwd instead
# of -C also drops the "Entering directory" banner on real builds.
skip_build = False
if not ninja_changed:
probe = subprocess.run(
[str(paths.ninja), "-n", *targets],
cwd=build_dir,
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if probe.stderr.strip():
# A load-time diagnostic (e.g. "multiple rules generate X")
# flags a generator bug; the skip branch would otherwise
# swallow it forever
_LOGGER.warning("ninja: %s", probe.stderr.strip())
if probe.returncode != 0:
# An unknown target here is the defective-manifest case; fall
# through to the real build so the error prints attributably
_LOGGER.debug("ninja probe failed; running the full build")
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
if skip_build:
_LOGGER.debug("ninja: nothing to rebuild")
else:
_LOGGER.debug("Running: %s", " ".join(cmd))
rc = subprocess.run(
cmd, cwd=build_dir, env=env, check=False, close_fds=False
).returncode
if rc != 0:
return rc
# ninja already refused a manifest missing the explicit targets above;
# existence covers the remaining hole (a rule that ran but wrote
# elsewhere). The factory/ota copies are what upload and OTA consume.
build_dir_artifacts = (
get_elf_path(),
build_dir / "firmware.bin",
get_factory_firmware_path(),
build_dir / "firmware.ota.bin",
)
for artifact in build_dir_artifacts:
if not artifact.is_file():
_LOGGER.error("Build produced no %s", artifact)
return 1
if not _print_size_summary(build_dir, paths):
# The cause was already warned; name the consequence so a build
# contributing no RAM/Flash metric is visible to CI harnesses
_LOGGER.warning("Firmware size summary unavailable for this build")
from esphome.build_helpers.idedata import warn_if_idedata_missing
warn_if_idedata_missing(lambda: get_idedata(ccache))
return 0
def _write_compile_commands(
ninja_path: Path, build_dir: Path, env: dict[str, str]
) -> None:
compdb = build_dir / "compile_commands.json"
result = subprocess.run(
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if result.returncode != 0:
# Drop any stale database so consumers (IDE integration, clang-tidy,
# the memory analyzer) can't silently read outdated data.
compdb.unlink(missing_ok=True)
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
try:
entries = json.loads(result.stdout)
except ValueError as err:
compdb.unlink(missing_ok=True)
raise EsphomeError(
f"ninja produced an unparsable compile database: {err} "
f"(output starts {result.stdout[:120]!r})"
) from err
if not entries:
# compdb exits 0 with [] for unknown rule names; a renamed compile
# rule must fail the build, not silently strand every consumer
compdb.unlink(missing_ok=True)
raise EsphomeError(
"ninja produced an empty compile database; the generator's rule "
"names no longer match"
)
# write_file_if_changed keeps the mtime stable on no-op builds so the
# idedata cache in get_idedata() stays valid.
write_file_if_changed(compdb, result.stdout)
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
from esphome.build_gen.arduino8266 import get_flash_ld_path
from esphome.components.esp8266.build_surgery import segment_length
# Warnings, not debug: without the app size the Flash summary line is
# dropped and CI's memory-impact extraction loses its flash metric.
ld_path = get_flash_ld_path(build_dir, paths)
try:
ld_text = ld_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as err:
# UnicodeDecodeError: a truncated/corrupt script must degrade to
# the same warning, never abort an already-linked build
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
return None
app_size = segment_length(ld_text, "irom0_0_seg")
if app_size is None:
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
return None
if app_size == 0:
_LOGGER.warning(
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
)
return None
return app_size
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
The exact shape (including the bar) is parsed by
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
"""
from esphome.build_helpers.size_summary import print_size_line
size_tool = _toolchain_tool("size")
try:
result = subprocess.run(
[str(size_tool), "-A", "-d", str(get_elf_path())],
capture_output=True,
text=True,
check=False,
close_fds=False,
)
except OSError as err:
# The summary is a bonus artifact like idedata; a truncated
# toolchain extraction must not discard an already-linked build
_LOGGER.warning("Could not summarize firmware size: %s", err)
return False
if result.returncode != 0:
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
return False
sections: dict[str, int] = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0].startswith("."):
try:
sections[parts[0]] = int(parts[1])
except ValueError:
# An unparsed RAM/Flash section trips the missing-sections
# guard below, so no total is built on a dropped value
_LOGGER.warning("Unparsable size output for section %s", parts[0])
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
# A defaulted 0 would print a confidently wrong total for CI's metric
_LOGGER.warning(
"Size output is missing section(s) %s; skipping the size summary",
", ".join(sorted(missing)),
)
return False
# Resolve the flash budget before printing anything: a RAM line without
# its Flash line would let CI's memory-impact extraction sum the two
# metrics over different build counts (_parse_app_size already warned).
app_size = _parse_app_size(build_dir, paths)
if not app_size:
return False
ram = sum(sections[s] for s in _RAM_SECTIONS)
flash = sum(sections[s] for s in _FLASH_SECTIONS)
print_size_line("RAM", ram, _MAX_RAM_SIZE)
print_size_line("Flash", flash, app_size)
return True
# Sentinel: "resolve for me"; None is a real value meaning disabled.
_CCACHE_UNRESOLVED: Any = object()
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
"""Derive idedata from the build's compile_commands.json.
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
integrations, clang-tidy, and the memory analyzer expect.
"""
from esphome.build_helpers.idedata import load_or_build_idedata
if ccache is _CCACHE_UNRESOLVED:
# Deliberately uncached: env/PATH can change between builds in a
# long-lived host process
ccache = resolve_ccache_path()
return load_or_build_idedata(
get_build_dir() / "compile_commands.json",
get_elf_path(),
# Suffixed so a platformio->arduino->platformio round trip on one
# config never serves the other toolchain's cache shape
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
# The compile DB's commands carry the same ccache prefix the ninja
# rules were generated with
launcher=str(ccache) if ccache else None,
)
File diff suppressed because it is too large Load Diff
-74
View File
@@ -4,14 +4,6 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_enabled,
pch_header_text,
)
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
@@ -287,67 +279,9 @@ idf_component_register(
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
)
{_pch_cmake()}"""
def _pch_cmake() -> str:
"""The src component's precompiled-header block (C++ TUs only).
The -include stays relative (resolved from the compiler cwd, the build
dir); an absolute path would poison ccache keys.
"""
if not pch_enabled():
return ""
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties(${{app_sources}} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
def discard_pch() -> None:
"""Drop the pch sidecars in the IDF build dir."""
pch.discard_pch(CORE.relative_build_path("build"))
def prepare_pch() -> None:
"""Build the .gch right before ninja, after every reconfigure, so the
compile_commands.json flags and the sdkconfig are the settled ones."""
if not pch_enabled():
# Self-cleaning escape hatch: drop any previously built .gch
pch.discard_pch(CORE.relative_build_path("build"))
return
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
try:
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
except OSError as err:
# Fail closed: the sdkconfig is the .sum's only config identity for
# sdkconfig.h-only options; a stand-in marker would collide
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
)
pch.discard_pch(CORE.relative_build_path("build"))
return
pch.prepare_pch(
CORE.relative_build_path("build"),
PCH_DEFAULT_HEADERS,
(
str(idf_version()),
CORE.cpp_standard or "",
sdkconfig,
*get_project_compile_flags(),
*get_project_cxx_compile_flags(),
),
)
def write_project(
minimal: bool = False, builtin_components: list[str] | None = None
) -> None:
@@ -367,14 +301,6 @@ def write_project(
get_component_cmakelists(),
)
if pch_enabled():
write_file_if_changed(
CORE.relative_build_path("build", PCH_HEADER_NAME),
pch_header_text(PCH_DEFAULT_HEADERS),
)
# Consumers carry the -include; gate the ccache relaxation on it
mark_pch_emitted()
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
+1 -14
View File
@@ -87,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"CCACHE_DIR": str(cache_dir),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
# A user value wins via the filter below
"CCACHE_BASEDIR": effective_ccache_basedir(),
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
def effective_ccache_basedir() -> str:
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
wins, else the resolved build path (matching ccache_defaults_env)."""
from esphome.core import CORE
raw = os.environ.get("CCACHE_BASEDIR")
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
return raw
# Unset or degenerate ("", "/", relative): fall back to the build path
return str(Path(CORE.build_path).resolve())
+15 -70
View File
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
from __future__ import annotations
from collections.abc import Callable
import json
import logging
import os
@@ -22,8 +21,6 @@ import subprocess
from esphome.core import EsphomeError
from esphome.helpers import write_file
_LOGGER = logging.getLogger(__name__)
# Everything idedata generation may raise after a successful link; idedata
# is a bonus artifact, so consumers warn instead of failing the build
IDEDATA_BEST_EFFORT_ERRORS = (
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
ValueError,
)
_LOGGER = logging.getLogger(__name__)
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
"""Run an idedata generator, downgrading any failure to a warning.
Shared by the native backends: the firmware already built, so a missing
or broken idedata must not fail a successful build.
"""
try:
if get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
if isinstance(err, (EsphomeError, OSError)):
# Routine environmental failures keep the detail at debug
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
# LookupError/ValueError/RuntimeError smell like a parsing bug;
# a permanently masked traceback would hide it on every build
_LOGGER.warning("Idedata failure detail", exc_info=True)
# C++ translation-unit suffixes.
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
# C++ translation-unit suffixes used to identify ESPHome source files.
_CXX_SUFFIXES = (".cpp", ".cc")
# Suffixes of input/output files that appear bare on the command line (and so
# must not be mistaken for compiler flags).
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
# Path marker identifying an ESPHome source translation unit.
_ESPHOME_SRC_MARKER = "/src/esphome/"
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
``/`` first since Windows compile DBs use backslashes."""
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
CXX_SOURCE_SUFFIXES
_CXX_SUFFIXES
)
def split_command(command: str) -> list[str]:
def _split_command(command: str) -> list[str]:
r"""Tokenize a compile_commands.json / response-file command string.
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
ctypes.windll.kernel32.LocalFree(argv)
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
GCC response files embed flags that must be expanded so GCC-only flags
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
rf = directory / rf
try:
out.extend(
expand_response_files(
split_command(rf.read_text(encoding="utf-8")), directory
_expand_response_files(
_split_command(rf.read_text(encoding="utf-8")), directory
)
)
continue
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
if _is_esphome_src(entry["file"]):
return entry
for entry in entries:
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
if entry["file"].endswith(_CXX_SUFFIXES):
return entry
raise ValueError("no C++ translation unit found in compile_commands.json")
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
def is_launcher(token: str) -> bool:
def _is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
def is_joined_include(tok: str) -> bool:
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
return (
tok.startswith("-include")
and tok != "-include"
and not tok.startswith("-include-")
)
def parse_entry(
entry: dict, launcher: str | None = None
) -> tuple[str, list[str], list[str], list[str]]:
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
directory = Path(entry["directory"])
tokens = expand_response_files(split_command(entry["command"]), directory)
tokens = _expand_response_files(_split_command(entry["command"]), directory)
def _include(raw: str) -> str:
# Resolve against the entry's ``directory`` so cached idedata works
@@ -191,7 +156,7 @@ def parse_entry(
if not tokens:
# An empty command, or one that was only the launcher; fail by name
raise ValueError(f"empty compile command for {entry.get('file')}")
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
# Stale DB built with a launcher this run no longer configures; the
# real compiler is the next token
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
@@ -204,23 +169,11 @@ def parse_entry(
defines: list[str] = []
includes: list[str] = []
cxx_flags: list[str] = []
unresolved_force_includes: list[str] = []
it = iter(tokens[1:])
for tok in it:
if tok in ("-c", "-o"):
next(it, None) # drop the flag and its argument (input/output)
elif tok == "-include" or is_joined_include(tok):
# Re-anchor only names next to the compile (the pch); a name
# meant for the -I chain must stay untouched
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
if not raw:
_LOGGER.warning("Dropping -include with no argument")
elif Path(resolved := _include(raw)).is_file():
cxx_flags.extend(("-include", resolved))
else:
unresolved_force_includes.append(raw)
cxx_flags.extend(("-include", raw))
elif tok.startswith("-D"):
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
# quoted arg with a space after -D) that some flags arrive as.
@@ -239,14 +192,6 @@ def parse_entry(
pass # input/output files
else:
cxx_flags.append(tok)
for raw in unresolved_force_includes:
# A deleted build artifact would otherwise surface only downstream
if not any((Path(inc) / raw).is_file() for inc in includes):
_LOGGER.warning(
"-include %s found neither next to the compile nor on the "
"include path; cached idedata may not resolve it",
raw,
)
return cxx_path, defines, includes, cxx_flags
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
if not isinstance(cached, dict) or "cc_path" not in cached:
return False
cxx_path = cached.get("cxx_path")
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
return False
includes = cached.get("includes")
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
@@ -359,7 +304,7 @@ def load_or_build_idedata(
def reject_launcher_compiler(cxx_path: str) -> None:
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
must never be probed, cached, or consumed."""
if is_launcher(cxx_path):
if _is_launcher(cxx_path):
raise EsphomeError(
f"compile_commands.json names the launcher {cxx_path} as the "
"compiler; the compile database is unusable"
-423
View File
@@ -1,423 +0,0 @@
"""Shared precompiled-header policy for the build backends.
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
it too; Arduino.h visibility there is intended (esphome#8693).
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
import json
import logging
import os
from pathlib import Path
import posixpath
import re
import stat
import subprocess
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.build_helpers.idedata import (
CXX_SOURCE_SUFFIXES,
expand_response_files,
is_launcher,
split_command,
)
_DOMAIN = "pch"
@dataclass
class _PCHData:
emitted: bool = False
def _pch_data() -> _PCHData:
from esphome.core import CORE
if _DOMAIN not in CORE.data:
CORE.data[_DOMAIN] = _PCHData()
return CORE.data[_DOMAIN]
def mark_pch_emitted() -> None:
"""Record that this build's consumers reference the pch."""
_pch_data().emitted = True
_LOGGER = logging.getLogger(__name__)
# The header and its .gch/.sum sidecars live in the build directory.
PCH_HEADER_NAME = "esphome_pch.h"
# Every artifact the pch machinery can leave behind, for cleanup.
PCH_ARTIFACT_NAMES = (
PCH_HEADER_NAME,
f"{PCH_HEADER_NAME}.gch",
f"{PCH_HEADER_NAME}.gch.sum",
f"{PCH_HEADER_NAME}.gch.failed",
)
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
# Prefix-header contents for backends that inject a curated set (rather
# than mirroring the TUs' own force-includes), defines.h first so USE_*
# macros exist for the rest. Deliberately hard-coded: frequency-derived
# sets measured no better and kept selecting headers that cannot compile
# standalone (X-macro, platform-variant). Every entry must be safe to
# include first in an empty TU. Caveat: application.h/automation.h become
# ambiently visible, so a TU missing those #includes still builds on such
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
PCH_DEFAULT_HEADERS = (
PCH_CORE_HEADER,
"esphome/core/component.h",
"esphome/core/helpers.h",
"esphome/core/log.h",
"esphome/core/application.h",
"esphome/core/automation.h",
)
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
# .sum sidecar instead of the .gch bytes, which are not reproducible.
# Keep in sync with the literals in platformio/pch.py.script.
_CCACHE_PCH_ENV = {
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
"CCACHE_PCH_EXTSUM": "true",
}
# Both include forms: an angle include resolving under src/ must enter the
# digest too; ones that do not resolve simply end the walk
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
def pch_enabled() -> bool:
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
Native backends export these process-wide; only time_macros affects
non-pch TUs."""
if not (pch_enabled() and _pch_data().emitted):
return {}
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
# ccache then hashes the non-reproducible .gch bytes: permanent misses
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
if user_sloppiness is not None and (
missing := [
t
for t in ("pch_defines", "time_macros")
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
]
):
# Without these ccache declines every pch-consuming compile
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
_LOGGER.warning(
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
"that use the precompiled header",
",".join(missing),
)
return env
def pch_extra_scripts() -> list[str]:
"""The extra_scripts entries a PlatformIO platform registers for the
pch; empty when disabled (the script itself has no enable check)."""
return ["post:pch.py"] if pch_enabled() else []
def pch_header_text(include_headers: Iterable[str]) -> str:
"""The prefix-header source: exactly these includes, in order."""
return "".join(f'#include "{name}"\n' for name in include_headers)
def _resolves(path: Path) -> bool:
"""False when missing; other stat failures propagate (identity unknown,
unlike is_file(), which would silently drop the header)."""
try:
return stat.S_ISREG(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
"""Include closure of ``roots``: src-relative name -> contents.
Resolution mirrors the compiler (includer's dir, then src root); names
outside ``src_dir`` end the walk and are versioned by the caller. No
#ifdef evaluation: over-approximating is the safe direction.
"""
seen: dict[str, bytes] = {}
stack: list[tuple[str, str]] = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and _resolves(src_dir / rel):
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
# A marker would truncate the transitive walk; fail closed
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
raise
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend(
# surrogateescape: a non-UTF-8 name just fails to resolve
(inc.decode(errors="surrogateescape"), parent)
for inc in _INCLUDE_RE.findall(data)
)
return seen
def pch_checksum(
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
) -> str:
"""Digest standing in for the .gch in ccache's hash: the include closure
of the prefix header plus caller-supplied identity strings (versioned
install paths, flags). Raises OSError when a header's identity cannot
be established at all; callers must then compile without a pch."""
digest = hashlib.sha256()
closure = _include_closure(src_dir, include_headers)
for name in sorted(closure):
digest.update(name.encode(errors="surrogateescape"))
digest.update(closure[name])
digest.update(b"\0")
for item in extra:
digest.update(item.encode(errors="surrogateescape"))
digest.update(b"\0")
return digest.hexdigest()
# Tokens dropped when retargeting a TU's flags at the prefix header
# (the pch compile must not touch depfiles)
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
def pch_compile_command(
build_dir: Path, header: Path, gch: Path
) -> tuple[list[str], Path] | None:
"""The exact src C++ flags from compile_commands.json retargeted at the
header, with the directory they resolve against (relative -I paths must
be expanded and executed from the same root); None (logged) when no
configured C++ TU is available yet."""
from esphome.core import CORE
try:
entries = json.loads(
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as err:
# Configure already succeeded, so an unusable DB is a real anomaly
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
return None
if not isinstance(entries, list):
_LOGGER.warning("Malformed compile database, skipping pch")
return None
# CMake may spell paths through a symlink differently than CORE does
# (macOS /tmp vs /private/tmp), so compare resolved paths
src_root = Path(CORE.relative_src_path()).resolve()
entry = next(
(
e
for e in entries
if isinstance(e, dict)
and isinstance(e.get("file"), str)
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
and Path(e["file"]).resolve().is_relative_to(src_root)
),
None,
)
if entry is None:
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
return None
directory = entry.get("directory")
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
command = entry.get("command")
tokens = expand_response_files(
split_command(command if isinstance(command, str) else ""), cmd_dir
)
# A DB recorded with ccache enabled prefixes the compiler with the
# launcher; the .gch must be compiled directly
if tokens and is_launcher(tokens[0]):
tokens = tokens[1:]
if not tokens:
# "arguments"-style or empty entries must skip, not spawn "-x ..."
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
return None
args: list[str] = []
arg_it = iter(tokens)
for tok in arg_it:
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
next(arg_it, None)
continue
if tok in _PCH_STRIP_FLAGS:
continue
if tok == "-include":
# Drop only the injected prefix; user force-includes must reach
# the .gch compile or GCC rejects it over the macro mismatch
inc = next(arg_it, "")
if not inc.endswith(PCH_HEADER_NAME):
args.extend(("-include", inc))
continue
args.append(tok)
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
def _log_pch_in_use() -> None:
# The only place a user can discover the knob; emitted only once a
# .gch is actually fresh or being built
_LOGGER.info(
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
)
def _read_stamp(path: Path) -> str:
"""A corrupt sidecar must read as stale, not kill the pch forever."""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def discard_pch(build_dir: Path) -> None:
"""Remove the pch sidecars so a stale .gch is never consumed.
Bumps the header only when a .gch was actually removed: TUs compiled
against it have incomplete depfiles, while a repeat failure with no
.gch must not force a full rebuild every build.
"""
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
had_gch = gch.is_file()
gch.unlink(missing_ok=True)
Path(f"{gch}.sum").unlink(missing_ok=True)
if had_gch and header.is_file():
os.utime(header)
def prepare_pch(
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
) -> None:
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
write its ccache .sum.
The .sum doubles as the freshness stamp and folds in the compile
command, so a flag-only change rebuilds the .gch; ``extra`` carries
backend identity (framework version, sdkconfig, ...). A failed
compile falls back to the plain header include.
"""
from esphome.core import CORE
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cmd_and_dir = pch_compile_command(build_dir, header, gch)
if cmd_and_dir is None:
# Freshness cannot be validated; a leftover .gch must not be consumed
discard_pch(build_dir)
return
cmd, cmd_dir = cmd_and_dir
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
# build path covers unresolved (symlinked) spellings
cmd_id = (
" ".join(cmd)
.replace(effective_ccache_basedir(), "")
.replace(str(CORE.build_path), "")
)
try:
checksum = pch_checksum(
CORE.relative_src_path(),
include_headers,
(
# The closure is sorted, so root order only enters via the text
pch_header_text(include_headers),
*extra,
cmd_id,
),
)
except (OSError, UnicodeError) as err:
# Identity unknown: a stale cache entry must never be served
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
discard_pch(build_dir)
return
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
return
failed_marker = Path(f"{gch}.failed")
if _read_stamp(failed_marker) == checksum:
_LOGGER.info(
"Precompiled header disabled after an earlier failure; delete %s to retry",
failed_marker,
)
return
_log_pch_in_use()
try:
result = subprocess.run(
cmd,
cwd=cmd_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
capture_output=True,
text=True,
check=False,
timeout=300,
)
error = None
if result.returncode < 0:
# Killed by a signal (OOM, ^C): environmental, do not latch
_LOGGER.warning(
"Precompiled header compile was killed (signal %d); retrying "
"next build",
-result.returncode,
)
discard_pch(build_dir)
return
if result.returncode != 0:
error = result.stderr.strip() or f"exit code {result.returncode}"
elif not gch.is_file():
error = "compiler produced no .gch"
except (OSError, subprocess.SubprocessError) as err:
# Transient (timeout, spawn/IO): warn and retry next build, no marker
_LOGGER.warning("Precompiled header compile did not run: %s", err)
discard_pch(build_dir)
return
if error is not None:
_LOGGER.warning(
"Precompiled header failed; compiling without it: %s", error[:400]
)
# This path latches, so keep the full compiler output recoverable
_LOGGER.debug("Full pch compile output: %s", error)
discard_pch(build_dir)
if any(m in error for m in _TRANSIENT_ERRORS):
# Resource exhaustion clears on its own; retry next build
return
# Skip retries until a header/flag/backend-identity/command change
failed_marker.write_text(checksum + "\n", encoding="utf-8")
os.utime(header)
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Consumers depend on the header (depfiles cannot see through a .gch);
# bump it so users of the previous .gch recompile
os.utime(header)
+41 -191
View File
@@ -3,10 +3,8 @@ from pathlib import Path
import platform
import re
import subprocess
import time
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -16,7 +14,6 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
CONF_TOOLCHAIN,
CONF_VERSION,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
@@ -24,7 +21,6 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
PLATFORM_ESP8266,
ThreadModel,
Toolchain,
)
from esphome.core import (
CORE,
@@ -35,13 +31,12 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
from .boards import BOARDS, board_ld_script
from .const import (
BUILD_FLASH_MODES,
CONF_EARLY_PIN_INIT,
CONF_ENABLE_SERIAL,
CONF_ENABLE_SERIAL1,
@@ -49,7 +44,6 @@ from .const import (
KEY_BOARD,
KEY_ESP8266,
KEY_PIN_INITIAL_STATES,
KEY_SCANF_FLOAT,
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
KEY_WAVEFORM_REQUIRED,
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
if not CORE.using_toolchain_arduino:
return config
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
conf = config[CONF_FRAMEWORK]
version = cv.Version.parse(conf[CONF_VERSION])
if version < MIN_FRAMEWORK_VERSION:
raise cv.Invalid(
"'toolchain: arduino' requires framework version "
f"{MIN_FRAMEWORK_VERSION} or newer"
)
# platform_version is a PlatformIO concept; drop it (as esp32's native
# toolchain does), warning when a custom pin is discarded. The floor
# above guarantees the schema-derived default is the ARDUINO_4 spec.
if (
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
!= _ARDUINO_4_PLATFORM_SPEC
):
_LOGGER.warning(
"'platform_version' is ignored by 'toolchain: arduino'; the native "
"toolchain downloads the framework and compiler directly"
)
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
raise cv.Invalid(
"'toolchain: arduino' does not support a custom framework source; "
"use 'toolchain: platformio'"
)
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
# a drift guard for the independently regenerated tables
if (
config[CONF_BOARD] not in BOARDS
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
):
raise cv.Invalid(
f"Board '{config[CONF_BOARD]}' is not supported by "
"'toolchain: arduino'; use 'toolchain: platformio'"
)
return config
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
"""Binary-download entries for a built ESP8266 firmware.
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
# a PIO platformio/framework-arduinoespressif8266 value
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
# Same encoding the native toolchain uses for its package download, so a
# custom-source check against this value cannot drift from what it fetches.
# version bump cannot drift between the two paths.
from esphome.arduino8266.framework import framework_package_version
try:
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
platform_version = value.get(CONF_PLATFORM_VERSION)
if platform_version is None:
if version >= cv.Version(3, 1, 0):
platform_version = _ARDUINO_4_PLATFORM_SPEC
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
else:
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
value[CONF_PLATFORM_VERSION] = platform_version
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
return value
# The platform_version derived for every core >= 3.1.0 config
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
)
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
cv.Optional(
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
): _validate_toolchain,
}
),
_resolve_toolchain,
_validate_native_toolchain,
# Until the native toolchain lands, PlatformIO is the only backend;
# reject a --toolchain this platform cannot serve yet.
cv.require_platformio_toolchain("ESP8266"),
set_core_data,
)
def native_toolchain_module():
"""The native build backend for the resolved toolchain, if any.
``__main__`` dispatches from its own toolchain-keyed table; this helper
serves the component's internal callers.
"""
if not CORE.using_toolchain_arduino:
return None
from esphome.arduino8266 import toolchain
return toolchain
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
use_platformio = CORE.using_toolchain_platformio
cg.add(esp8266_ns.setup_preferences())
if use_platformio:
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_build_flag("-DUSE_ESP8266")
cg.set_cpp_standard("gnu++20")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
"enabling scanf float support (~8KB flash)"
)
# The native generator reads the same decision (KEY_SCANF_FLOAT)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
if use_platformio:
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.extend(pch_extra_scripts())
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
cg.add_build_flag("-Wno-nonnull-compare")
if use_platformio:
cg.add_platformio_option("framework", "arduino")
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
# Default for platformio is LWIP2_LOW_MEMORY with:
# - MSS=536
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
# See throw_stubs.h for details. Must be prepended before <string>, so this
# uses build_src_flags with -include. Unconditional: the native build
# generator reads the same option, keeping one source of truth.
# uses build_src_flags with -include.
cg.add_platformio_option(
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
)
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
# implementation in the Arduino ESP8266 core.
cg.add_build_flag("-Wl,--wrap=millis")
# Unconditional: the native build generator reads the same option,
# keeping one source of truth
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
)
if use_platformio and config[CONF_BOARD] in BOARDS:
if config[CONF_BOARD] in BOARDS:
cg.add_platformio_option(
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
)
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
# Called by __main__.compile_program; returning False falls through to the
# PlatformIO toolchain.
def run_compile(args, config: ConfigType) -> bool:
# Positive check: the native backend only runs when explicitly resolved
toolchain = native_toolchain_module()
if toolchain is None:
return False
if toolchain.run_compile(config, CORE.verbose) != 0:
raise EsphomeError("ESP8266 native build failed")
return True
# Called by writer.py
def copy_files() -> None:
# Native builds skip the PlatformIO extra scripts; the build generator
# carries their logic
if CORE.using_toolchain_arduino:
return
dir = Path(__file__).parent
for script in (
"post_build",
@@ -565,7 +469,6 @@ def copy_files() -> None:
CORE.relative_build_path(f"{script}.py"),
)
copy_ccache_script()
copy_pch_script()
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
}
_DECODE_WARNED_AT: dict[str, float] = {}
def _decode_pc(config: ConfigType, addr: str) -> None:
from esphome.platformio import toolchain
def _warn_decode_problem(key: str, message: str, *args) -> bool:
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
once but a later dump warns again; returns whether it warned so the
caller can mark suppressed addresses individually."""
now = time.monotonic()
last = _DECODE_WARNED_AT.get(key)
if last is not None and now - last < 30:
return False
_DECODE_WARNED_AT[key] = now
_LOGGER.warning(message, *args)
return True
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
"""Decode one crash address. ``bulk``: the caller is scanning every
8-hex stack word, most of which are not code addresses -- unmappable
ones log at debug so real frames are not buried."""
if (native_toolchain := native_toolchain_module()) is not None:
addr2line = native_toolchain.get_addr2line_path()
elf = native_toolchain.get_elf_path()
for path in (addr2line, elf):
if not path.is_file():
_warn_decode_problem(
str(path), "Cannot decode crash addresses: %s missing", path
)
# The detailed warning names no address; mark named
# registers, but bulk stack words at debug (~150 per dump)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (toolchain file missing)", addr)
return
addr2line, elf = str(addr2line), str(elf)
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_warn_decode_problem(
"no-addr2line",
"Cannot decode crash addresses: no addr2line or ELF in idedata",
)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (no addr2line or ELF)", addr)
return
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
command = [addr2line, "-pfiaC", "-e", elf, addr]
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Warn, not debug: a failing addr2line must be visible. The warning
# is rate-limited across a dump, so mark every undecoded address
# inline or the rest read as merely unmappable
if not _warn_decode_problem(
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
):
# The detailed warning already named this address; mark only
# the rate-limited ones, and bulk stack words at debug
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (addr2line failed)", addr)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return
if "?? ??:0" in translation:
# A named register that fails to decode is confusing silence; a
# bulk stack word failing is the expected common case
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (address not in %s)", addr, elf)
# Nothing useful
return
translation = translation.replace(" at ??:?", "").replace(":?", "")
_LOGGER.warning("Decoded %s", translation)
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
if backtrace_state:
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
_decode_pc(config, addr.group(), bulk=True)
_decode_pc(config, addr.group())
return backtrace_state
+1 -3
View File
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_FLASH_MODE = "flash_mode"
KEY_SCANF_FLOAT = "scanf_float"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
enable_serial1()
"""
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
+2 -7
View File
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
import subprocess
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -34,7 +33,7 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option(
"extra_scripts",
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
@@ -648,7 +644,6 @@ def copy_files() -> None:
CORE.relative_build_path("inject_lwip_include.py"),
)
copy_ccache_script()
copy_pch_script()
_generate_lwipopts_h()
if generate_pio_files():
path = CORE.relative_src_path("esphome.h")
+1 -8
View File
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
# that is stored rather than translated away. Consumed by the esp8266 native
# backend (later in this chain) for its ignored-option warning; defined here
# so it stays adjacent to the routing.
# build_src_flags and board_build.flash_mode: set unconditionally by
# esp8266/__init__ and read by the native generator; not user-routable, so
# not in the set above
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
"lib_ignore",
"build_src_flags",
"board_build.flash_mode",
}
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
@coroutine_with_priority(CoroPriority.FINAL)
+17 -27
View File
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
parse_enable_env,
resolve_ccache_path,
)
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
from esphome.core import Version
from esphome.framework_helpers import (
@@ -1206,33 +1205,15 @@ def _ccache_env() -> dict[str, str]:
Only values the user has not already set in the environment are returned, so
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
The pch settings add ``time_macros`` sloppiness process-wide; the visible
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
"""
if not _ccache_enabled():
# The raw knob value (e.g. "disable") is still inherited by idf.py
# via os.environ, where a non-false-constant string reads as
# truthy; export the canonical off spelling instead
return {"IDF_CCACHE_ENABLE": "0"}
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
env.update(ccache_pch_env())
# Exactly one canonical spelling ever reaches idf.py, whatever the
# accepted input spelling was ("enable", "yes", ...)
env["IDF_CCACHE_ENABLE"] = "1"
return env
def _ccache_enabled() -> bool:
"""Whether ESP-IDF compiles run under ccache.
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
on PATH.
"""
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
# ESPHOME_CCACHE_ENABLE.
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
if idf_knob is False:
return False
# The raw value (e.g. "disable") is still inherited by idf.py via
# 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 True:
# Forced on ignores the runnability verdict, but the outcome is
# worth saying out loud. Probed directly (not via the resolver,
@@ -1252,8 +1233,17 @@ def _ccache_enabled() -> bool:
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
"it failed to run; idf.py will use it anyway",
)
return True
return resolve_ccache_path() is not None
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")
# Exactly one canonical spelling ever reaches idf.py, whatever the
# accepted input spelling was ("enable", "yes", ...)
env["IDF_CCACHE_ENABLE"] = "1"
return env
def get_framework_env(
-15
View File
@@ -1,6 +1,5 @@
"""ESP-IDF direct build API for ESPHome."""
from contextlib import suppress
from dataclasses import dataclass, field
import hashlib
import json
@@ -529,20 +528,6 @@ def run_compile(config, verbose: bool) -> int:
return result.returncode
_patch_memory_segments()
# After every reconfigure so compile_commands and sdkconfig are settled.
# An optional speedup must never abort the build
from esphome.build_gen.espidf import discard_pch, prepare_pch
try:
prepare_pch()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Discard so a stale .gch can never be consumed
with suppress(OSError):
discard_pch()
_LOGGER.warning(
"Precompiled header setup failed; compiling without it", exc_info=True
)
# Build
args = []
-364
View File
@@ -1,364 +0,0 @@
import hashlib
import os
from pathlib import Path
import posixpath
import re
import shlex
import stat
import subprocess
import traceback
# pylint: disable=E0602
Import("env") # noqa: F821
_projenv_error = None
try:
Import("projenv") # noqa: F821
except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
projenv = None
_projenv_error = err
# Precompile the src force-includes plus defines.h and force-include the
# result into C++ src compiles only; their preprocessed output is unchanged.
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
# values, and stamp flow in sync with build_helpers/pch.py.
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
_CORE_HEADER = "esphome/core/defines.h"
def _raise_walk_error(err: OSError) -> None:
raise err
def _resolves_dir(path: Path) -> bool:
"""False when missing; other stat failures propagate."""
try:
return stat.S_ISDIR(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _resolves(path: Path) -> bool:
"""False when missing; other stat failures propagate (identity unknown)."""
try:
return stat.S_ISREG(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _include_closure(src_dir: Path, roots: list) -> dict:
"""Quoted-include closure: src-relative name -> contents (mirror of
build_helpers/pch.py)."""
seen = {}
stack = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and _resolves(src_dir / rel):
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
# A marker would truncate the transitive walk; fail closed
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
raise
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend(
(inc.decode(errors="surrogateescape"), parent)
for inc in _INCLUDE_RE.findall(data)
)
return seen
def _shell_arg(element) -> str | None:
"""One compiler argv from one SCons element, matching the real spawn:
spaced elements pass whole, the rest get one shell unquote (skipped on
Windows, where shlex would eat path backslashes)."""
arg = str(element)
if " " in arg or os.name == "nt":
return arg.replace('\\"', '"')
if not arg.strip():
return arg
try:
tokens = shlex.split(arg)
except ValueError as err:
print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}")
return None
if len(tokens) != 1:
# A flag the model cannot reproduce would diverge the .gch's flags
print(f"ESPHome: cannot model flag {arg!r} for the pch")
return None
return tokens[0]
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
"""Compile the .gch, then probe that the toolchain can load it back
(GCC 10 on macOS arm64 rejects its own per-process). Returns an error
string or None; OSError propagates as transient."""
result = subprocess.run( # noqa: PLW1510
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
cwd=proj_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
capture_output=True,
text=True,
)
if result.returncode < 0:
# Signal-killed (OOM, ^C): route to the transient no-marker path
raise OSError(f"compiler killed by signal {-result.returncode}")
if result.returncode != 0:
return result.stderr
# -MF is only legal alongside a dependency flag; pass it solely to
# redirect a depfile that -MD/-MMD in the flags would otherwise write
dep_redirect = (
["-MF", os.devnull]
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
else []
)
probe = subprocess.run( # noqa: PLW1510
[
cxx,
*flags,
*dep_redirect,
"-Winvalid-pch",
"-include",
str(header),
"-fsyntax-only",
"-x",
"c++",
"-",
],
cwd=proj_dir,
env={**os.environ, "LC_ALL": "C"},
input="",
capture_output=True,
text=True,
)
if probe.returncode < 0:
raise OSError(f"probe killed by signal {-probe.returncode}")
if probe.returncode != 0 or ".gch" in probe.stderr:
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
return None
def _read_stamp(path: Path) -> str:
"""A corrupt sidecar must read as stale, not kill the pch forever."""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _setup_pch() -> None:
if projenv is None:
# Expected under -t nobuild; anything else must leave a trail
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
return
# Project root: SCons compiles run here, so the relative -include
# resolves; an absolute path would break cross-device ccache sharing.
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
header = proj_dir / "esphome_pch.h"
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cxx = projenv.subst("$CXX") # noqa: F821
# The header holds the -include entries itself, so the .gch compile must
# not see them; consumers keep theirs, which the .gch then satisfies.
flags = []
include_headers = []
raw_args = [
_shell_arg(element)
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
]
if any(arg is None for arg in raw_args):
print("ESPHome: skipping precompiled header: unmodelable flag")
return
flag_it = iter(raw_args)
for tok in flag_it:
if tok == "-include":
include_headers.append(next(flag_it, ""))
elif tok.startswith("-include") and not tok.startswith("-include-"):
include_headers.append(tok[len("-include") :])
else:
flags.append(tok)
if any(not name for name in include_headers):
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
return
# Fold only relative names resolving under src/: consumers keep their
# own -include entries, so folding an unguarded user header would
# include it twice; unfolded ones stay consumer-only.
try:
folded = [
name
for name in include_headers
if not Path(name).is_absolute() and _resolves(src_dir / name)
]
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
if unfolded := [n for n in include_headers if n not in folded]:
print(f"ESPHome: not precompiling non-src force-includes: {unfolded}")
content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER))
digest = hashlib.sha256()
digest.update(content.encode(errors="surrogateescape"))
digest.update(cxx.encode(errors="surrogateescape"))
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
# configs produce identical .sum files and share cache entries
flags_id = " ".join(flags)
if basedir := os.environ.get("CCACHE_BASEDIR"):
flags_id = flags_id.replace(basedir, "")
digest.update(flags_id.encode(errors="surrogateescape"))
# GCC never validates a .gch against its source headers, and PlatformIO
# package paths carry no version, so a package bump must invalidate here
platform = env.PioPlatform() # noqa: F821
for package in sorted(platform.packages):
try:
version = platform.get_package_version(package)
except KeyError:
# An unresolved manifest must not hash as a constant
if platform.get_package(package) is not None:
print(f"ESPHome: skipping precompiled header: no version for {package}")
return
version = None # absent optional package
except Exception as err: # noqa: BLE001
# No trustworthy package identity: a stale .gch could survive
print(f"ESPHome: skipping precompiled header: {err}")
return
digest.update(f"{package}={version}".encode())
digest.update(b"\0")
try:
closure = _include_closure(src_dir, [*folded, _CORE_HEADER])
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
for rel in sorted(closure):
digest.update(rel.encode(errors="surrogateescape"))
digest.update(closure[rel])
digest.update(b"\0")
# Project-local -I dirs (e.g. rp2's lwip_override) hold generated
# headers the src closure cannot see; hash them too
prev = ""
try:
for tok in flags:
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
if prev == "-I":
inc = tok
prev = tok
if not inc:
continue
inc_dir = Path(inc)
if not (
_resolves_dir(inc_dir)
and inc_dir.is_relative_to(proj_dir)
and not inc_dir.is_relative_to(src_dir)
# Library trees never enter the prefix closure; walking them
# would read every library file each build
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
):
continue
local_headers = []
# os.walk with onerror: rglob would swallow unlistable subtrees
for root, _dirs, files in os.walk(inc_dir, onerror=_raise_walk_error):
local_headers.extend(
Path(root) / f
for f in files
if f.endswith((".h", ".hpp", ".hh", ".inc"))
)
for local in sorted(local_headers):
digest.update(str(local.relative_to(proj_dir)).encode())
digest.update(local.read_bytes())
digest.update(b"\0")
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
checksum = digest.hexdigest()
# The ccache .sum sidecar doubles as the freshness stamp
if (
not header.is_file()
or not gch.is_file()
or not sum_path.is_file()
or (_read_stamp(sum_path) != checksum)
):
failed_marker = Path(f"{gch}.failed")
if _read_stamp(failed_marker) == checksum:
print(
"ESPHome: skipping precompiled header (previous attempt "
f"failed); delete {failed_marker.name} to retry"
)
return
header.write_text(content, encoding="utf-8")
try:
error = _compile_gch(cxx, flags, header, gch, proj_dir)
except OSError as err:
# Transient spawn/IO failure: no marker, retry next build
print(f"ESPHome: precompiled header compile did not run: {err}")
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
return
if error is not None:
print("ESPHome: precompiled header failed; compiling without it")
print(error)
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
if any(m in error for m in _TRANSIENT_ERRORS):
# Resource exhaustion clears on its own; retry next build
return
# Skip retries until a flag/header/platform change alters the checksum
failed_marker.write_text(checksum + "\n", encoding="utf-8")
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Computed first so the flags and env land together: a raise between
# them would leave a pch-consuming build without its ccache settings.
# projenv["ENV"] aliases os.environ, so these reach all TUs; only
# time_macros affects non-pch TUs. User values win.
ccache_updates = {
key: value
for key, value in (
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
("CCACHE_PCH_EXTSUM", "true"),
)
if key not in os.environ
}
sloppiness = os.environ.get("CCACHE_SLOPPINESS")
if sloppiness is not None:
tokens = {tok.strip() for tok in sloppiness.split(",")}
missing = [t for t in ("pch_defines", "time_macros") if t not in tokens]
if missing:
# Without these ccache declines every pch-consuming compile
ccache_updates["CCACHE_SLOPPINESS"] = ",".join((sloppiness, *missing))
print(f"ESPHome: adding {','.join(missing)} to CCACHE_SLOPPINESS for the pch")
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
# ccache then hashes the non-reproducible .gch bytes: permanent misses
print(f"ESPHome: CCACHE_PCH_EXTSUM={extsum} disables pch caching")
# Prepended: GCC only uses a .gch while no other tokens precede it.
# The relative name also reaches "pio run -t idedata" output.
# -Wno-error: the per-process probe can pass while a later cc1plus
# rejects the .gch; that must stay a warning under user -Werror.
projenv.Prepend( # noqa: F821
CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name]
)
projenv["ENV"].update(ccache_updates) # noqa: F821
print("ESPHome: Compiling with precompiled header")
try:
_setup_pch()
except Exception: # noqa: BLE001 -- a speedup must never break the build
print("ESPHome: pch internal error; compiling without it")
traceback.print_exc()
-9
View File
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
)
def copy_pch_script() -> None:
"""Copy the shared precompiled-header SCons post-script into the build
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
copy_file_if_changed(
Path(__file__).parent / "pch.py.script",
CORE.relative_build_path("pch.py"),
)
def default_libdeps_dir() -> str:
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
prefetch must resolve installed libraries against the same dir."""
+4 -14
View File
@@ -7,7 +7,6 @@ import re
import time
from esphome import loader
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
from esphome.compiled_config import save_compiled_config
from esphome.config import iter_component_configs, iter_components
from esphome.const import (
@@ -610,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
if idf_path.is_dir():
_LOGGER.info("Deleting %s", idf_path)
rmtree(idf_path)
# The PlatformIO pch artifacts live at the project root so the
# relative -include resolves; a partial clean must drop them too
for name in PCH_ARTIFACT_NAMES:
CORE.relative_build_path(name).unlink(missing_ok=True)
# The idedata caches are derived from the build but live under the data
# dir, not the build path, so they must be removed separately in both
# modes. Globbed (name.json plus name.<backend>.json) so a future
# backend suffix cannot silently drift out of clean-all.
idedata_dir = CORE.relative_internal_path("idedata")
for idedata_cache in (
*idedata_dir.glob(f"{CORE.name}.json"),
*idedata_dir.glob(f"{CORE.name}.*.json"),
):
# The idedata cache is derived from the build but lives under the data dir,
# not the build path, so it must be removed separately in both modes.
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if idedata_cache.is_file():
_LOGGER.info("Deleting %s", idedata_cache)
idedata_cache.unlink()
+1 -1
View File
@@ -1,6 +1,6 @@
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
cryptography==50.0.1; platform_system != "Darwin" or platform_machine != "x86_64"
cryptography==50.0.0; platform_system != "Darwin" or platform_machine != "x86_64"
cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
voluptuous==0.16.0
PyYAML==6.0.3
+24 -132
View File
@@ -50,7 +50,6 @@ from __future__ import annotations
import argparse
from collections import Counter
from collections.abc import Callable
from enum import StrEnum
from functools import cache
import json
@@ -532,53 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
# - script/test_build_components.py -- the harness the job invokes
# - .github/workflows/ci.yml -- the job's own definition
# Shared by every toolchain smoke-test job: the harness it invokes and the
# workflow that defines it
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/platformio.py",
"script/test_build_components.py",
".github/workflows/ci.yml",
}
)
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
"esphome/build_gen/platformio.py",
}
def _path_or_file_trigger(
files: list[str],
trigger_files: frozenset[str],
trigger_prefixes: tuple[str, ...],
) -> bool:
"""Whether any changed file matches the given infrastructure triggers."""
return any(
file in trigger_files or file.startswith(trigger_prefixes) for file in files
)
@cache
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
"""Dependency closure of the changed components, from the changed files.
The walk is expensive and every toolchain smoke-test job asks for the
same file list, so compute it once per run."""
component_files = [f for f in files if filter_component_and_test_files(f)]
return frozenset(get_components_with_dependencies(component_files, True))
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
return _path_or_file_trigger(
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
)
for file in files:
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
return True
if any(
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
):
return True
return False
def _esp_idf_infra_changed(files: list[str]) -> bool:
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
return _path_or_file_trigger(
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
)
for file in files:
if file in ESP_IDF_INFRA_TRIGGER_FILES:
return True
if any(
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
):
return True
return False
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
@@ -616,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
Returns:
Sorted list of component names to compile.
"""
return _toolchain_components_to_test(
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
)
def _toolchain_components_to_test(
branch: str | None,
test_set: frozenset[str],
infra_trigger: Callable[[list[str]], bool],
) -> list[str]:
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
files = changed_files(branch)
if core_changed(files) or infra_trigger(files):
return sorted(test_set)
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
return sorted(test_set & _cached_components_closure(tuple(files)))
component_files = [f for f in files if filter_component_and_test_files(f)]
changed = get_components_with_dependencies(component_files, True)
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
def should_run_esp32_platformio(branch: str | None = None) -> bool:
@@ -653,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
return bool(esp32_platformio_components_to_test(branch))
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
# converted registry libraries, and the waveform path.
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
{
"esp8266",
"api",
"web_server",
"captive_portal",
"mqtt",
"esp8266_pwm",
"neopixelbus",
"bme280_i2c",
"uart",
}
)
# Infrastructure whose changes always trigger the native ESP8266 compile
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
# the backend shares with the native ESP-IDF build.
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
"esphome/arduino8266/",
"esphome/arduino/",
"esphome/build_helpers/",
)
# Shared library-conversion modules every native build imports; espidf-only
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
{
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
# (now defined in clang_tidy_hash), or its smoke test silently skips them
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
ESP8266_NATIVE_TRIGGER_FILES = (
_NATIVE_SHARED_TRIGGER_FILES
| _SMOKE_HARNESS_TRIGGER_FILES
| {
"esphome/build_gen/arduino8266.py",
"esphome/build_gen/build_tool.py",
"esphome/components/esp8266/build_surgery.py",
"esphome/components/esp8266/boards.py",
"esphome/platformio/registry.py",
# esp8266/__init__.py imports copy_ccache_script from it
"esphome/platformio/toolchain.py",
".github/actions/cache-arduino8266/action.yml",
}
)
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
# base_python_changed covers the top-level esphome/*.py modules the
# native backend imports directly (framework_helpers, helpers, writer,
# __main__); without it a change there would silently skip this job.
# base_python_changed is deliberately broad (any top-level esphome/*.py)
# as belt-and-braces while the backend is new; narrow it to the modules
# the backend imports once the toolchain has soaked a few releases
return base_python_changed(files) or _path_or_file_trigger(
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
)
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
list on core or infrastructure changes, otherwise the intersection with
the changed-component dependency closure (empty list skips the job).
"""
return _toolchain_components_to_test(
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
)
def determine_cpp_unit_tests(
branch: str | None = None,
) -> tuple[bool, list[str]]:
@@ -1328,8 +1226,6 @@ def main() -> None:
run_device_builder = True
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
run_esp32_platformio = True
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
run_esp8266_native = True
else:
integration_run_all, integration_test_files = determine_integration_tests(
args.branch
@@ -1341,8 +1237,6 @@ def main() -> None:
run_device_builder = should_run_device_builder(args.branch)
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
run_esp32_platformio = bool(esp32_platformio_components)
esp8266_native_components = esp8266_native_components_to_test(args.branch)
run_esp8266_native = bool(esp8266_native_components)
run_integration, integration_test_buckets = _compute_integration_test_buckets(
integration_run_all, integration_test_files
)
@@ -1538,8 +1432,6 @@ def main() -> None:
"device_builder": run_device_builder,
"esp32_platformio": run_esp32_platformio,
"esp32_platformio_components": ",".join(esp32_platformio_components),
"esp8266_native": run_esp8266_native,
"esp8266_native_components": ",".join(esp8266_native_components),
"changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": list(directly_changed_with_tests),
+8 -50
View File
@@ -1027,7 +1027,6 @@ def test_components(
isolated_components: set[str] | None = None,
base_only: bool = False,
toolchain: str | None = None,
fail_on_no_tests: bool = False,
) -> int:
"""Test components with optional intelligent grouping.
@@ -1062,34 +1061,20 @@ def test_components(
# toolchain build.
include_validate = esphome_command != "compile"
# Find all component tests; remember which components each pattern
# (wildcards included) matched, for the deferred no-tests accounting
# Find all component tests
all_tests = {}
pattern_components: dict[str, set[str]] = {}
for pattern in component_patterns:
# Skip empty patterns (happens when components list is empty string)
if not pattern:
continue
found = find_component_tests(
tests_dir, pattern, base_only, include_validate=include_validate
all_tests.update(
find_component_tests(
tests_dir, pattern, base_only, include_validate=include_validate
)
)
pattern_components[pattern] = set(found)
all_tests.update(found)
# The flag's contract is "no test matched fails": a fully blank pattern
# list would otherwise slide into the reference-baseline fallback and
# exit green while building nothing a caller asked for
if fail_on_no_tests and not any(component_patterns):
print("No components requested (blank component list)")
return 1
if fail_on_no_tests and not all_tests:
# Nothing matched at all: fail before the synthetic baseline build,
# which would spend a compile reporting success on nothing. Partial
# matches defer to the per-pattern accounting after the summary.
print(f"No components found matching: {component_patterns}")
return 1
# If no components found, build a reference configuration for baseline comparison
# Create a synthetic "empty" component test that will build just the base config
if not all_tests:
print(f"No components found matching: {component_patterns}")
print(
@@ -1193,26 +1178,6 @@ def test_components(
toolchain=toolchain,
)
silent: list[str] = []
if fail_on_no_tests:
# A green run that built nothing for a requested pattern (renamed
# fixture, missing base file, version-suffix mismatch, a wildcard
# matching no component) must not pass CI. Per pattern: an
# all-or-nothing check would let one silent pattern hide behind the
# others. Opt-in: some legs (the esp32-ard smoke subset)
# legitimately match nothing. Failing is deferred past the summary
# so a real failure's reproduce commands still print.
built = {c for r in test_results for c in r.components}
# A pattern is silent when it matched no fixture, or when none of
# its matched components produced a build (wildcards included)
silent = [
p
for p in component_patterns
if p and not (pattern_components.get(p, set()) & built)
]
if silent:
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
# Separate results into passed and failed
passed_results = [r for r in test_results if r.success]
failed_results = [r for r in test_results if not r.success]
@@ -1244,7 +1209,7 @@ def test_components(
if os.environ.get("GITHUB_STEP_SUMMARY"):
write_github_summary(test_results, toolchain=toolchain)
if failed_results or silent:
if failed_results:
return 1
return 0
@@ -1299,12 +1264,6 @@ def main() -> int:
"--toolchain",
help="Select toolchain for compiling.",
)
parser.add_argument(
"--fail-on-no-tests",
action="store_true",
help="Exit non-zero when no test matched (for CI legs whose "
"components must all have fixtures)",
)
args = parser.parse_args()
@@ -1323,7 +1282,6 @@ def main() -> int:
continue_on_fail=args.continue_on_fail,
enable_grouping=not args.no_grouping,
isolated_components=isolated_components,
fail_on_no_tests=args.fail_on_no_tests,
base_only=args.base_only,
toolchain=args.toolchain,
)
-92
View File
@@ -78,17 +78,6 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture
def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]:
"""Mock esp8266_native_components_to_test from determine_jobs.
main() drives both the ``esp8266_native`` boolean output and the
``esp8266_native_components`` CSV from this one function.
"""
with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock:
yield mock
@pytest.fixture
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
"""Mock determine_cpp_unit_tests from helpers."""
@@ -117,7 +106,6 @@ def clear_determine_jobs_caches() -> None:
"""Clear all cached functions before each test."""
determine_jobs._is_clang_tidy_full_scan.cache_clear()
determine_jobs._component_has_tests.cache_clear()
determine_jobs._cached_components_closure.cache_clear()
def test_main_all_tests_should_run(
@@ -128,7 +116,6 @@ def test_main_all_tests_should_run(
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_esp32_platformio_components_to_test: Mock,
mock_esp8266_native_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -145,7 +132,6 @@ def test_main_all_tests_should_run(
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"]
mock_esp8266_native_components_to_test.return_value = ["api", "logger"]
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -222,8 +208,6 @@ def test_main_all_tests_should_run(
assert output["device_builder"] is True
assert output["esp32_platformio"] is True
assert output["esp32_platformio_components"] == "api,esp32"
assert output["esp8266_native"] is True
assert output["esp8266_native_components"] == "api,logger"
assert output["changed_components"] == ["wifi", "api", "sensor"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -260,7 +244,6 @@ def test_main_no_tests_should_run(
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_esp32_platformio_components_to_test: Mock,
mock_esp8266_native_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -277,7 +260,6 @@ def test_main_no_tests_should_run(
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_esp32_platformio_components_to_test.return_value = []
mock_esp8266_native_components_to_test.return_value = []
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files
@@ -320,8 +302,6 @@ def test_main_no_tests_should_run(
assert output["device_builder"] is False
assert output["esp32_platformio"] is False
assert output["esp32_platformio_components"] == ""
assert output["esp8266_native"] is False
assert output["esp8266_native_components"] == ""
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
@@ -3171,78 +3151,6 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
@pytest.mark.parametrize(
"changed",
[
"esphome/arduino8266/framework.py",
"esphome/build_gen/arduino8266.py",
"esphome/components/esp8266/build_surgery.py",
# Shared modules the native build depends on
"esphome/build_helpers/idedata.py",
"esphome/platformio/library.py",
# Top-level esphome/*.py modules the backend imports directly
"esphome/framework_helpers.py",
"esphome/writer.py",
# esp8266/__init__.py imports copy_ccache_script from it
"esphome/platformio/toolchain.py",
# The composite cache action must not ship unexercised
".github/actions/cache-arduino8266/action.yml",
],
)
def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None:
"""Native-ESP8266 infrastructure changes run the full test list."""
with (
patch.object(determine_jobs, "changed_files", return_value=[changed]),
patch.object(
determine_jobs,
"get_components_with_dependencies",
return_value=["wifi"],
),
):
result = determine_jobs.esp8266_native_components_to_test()
assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS)
@pytest.mark.parametrize(
("changed_files", "dependency_closure", "expected"),
[
# Tested component changed -- narrow to the intersection.
(
["esphome/components/mqtt/mqtt_client.cpp"],
["mqtt", "json"],
["mqtt"],
),
# Components outside the test set return an empty list (job skipped).
(
["esphome/components/wifi/wifi_component.cpp"],
["wifi", "network"],
[],
),
# espidf infrastructure is not an esp8266-native trigger; the
# native backend depends on esphome/build_helpers/ instead.
(["esphome/build_gen/espidf.py"], [], []),
(["esphome/espidf/toolchain.py"], [], []),
(["README.md"], [], []),
],
)
def test_esp8266_native_components_to_test_narrowing(
changed_files: list[str],
dependency_closure: list[str],
expected: list[str],
) -> None:
"""Component changes narrow the native-ESP8266 test list."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
patch.object(
determine_jobs,
"get_components_with_dependencies",
return_value=dependency_closure,
),
):
result = determine_jobs.esp8266_native_components_to_test()
assert result == expected
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
"""Without recorded durations the fan-out stays at the maximum."""
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
@@ -236,98 +236,3 @@ def test_run_grouped_test_closes_group_when_subprocess_raises(
)
assert "::endgroup::" in capsys.readouterr().out
def test_components_empty_match_fails_with_flag(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Under --fail-on-no-tests, a real component filtered to a platform
with no matching test file must not pass CI as a green zero-component
compile."""
rc = tbc.test_components(
["logger"],
"zz-none",
"compile",
False,
enable_grouping=False,
fail_on_no_tests=True,
)
assert rc == 1
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
def test_components_component_with_no_base_file_fails_with_flag(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A component whose fixture matches the platform but whose platform has
no base file builds nothing; under the flag that silent zero fails by
component name instead of hiding behind other components."""
monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {})
rc = tbc.test_components(
["logger"],
"esp8266-ard",
"compile",
False,
enable_grouping=False,
fail_on_no_tests=True,
)
assert rc == 1
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
def test_components_blank_list_fails_with_flag(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A fully blank component list must not slide into the baseline
fallback and exit green under the flag."""
rc = tbc.test_components(
[""], "esp8266-ard", "compile", False, fail_on_no_tests=True
)
assert rc == 1
assert "blank component list" in capsys.readouterr().out
def test_components_wildcard_no_match_fails_with_flag(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A wildcard matching nothing must not degrade to the synthetic
baseline build and exit green under the flag."""
rc = tbc.test_components(
["zz_no_such*"],
"esp8266-ard",
"compile",
False,
enable_grouping=False,
fail_on_no_tests=True,
)
assert rc == 1
assert "No components found matching" in capsys.readouterr().out
def test_components_empty_match_tolerated_without_flag() -> None:
"""The esp32-ard smoke leg deliberately builds only the subset with a
matching fixture; without the flag an empty match stays green."""
assert (
tbc.test_components(
["logger"], "zz-none", "compile", False, enable_grouping=False
)
== 0
)
def test_components_unknown_component_fails_with_flag(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A renamed smoke-test component must shrink coverage loudly, not fall
into the reference-baseline build."""
rc = tbc.test_components(
["no_such_component_xyz"],
"esp8266-ard",
"compile",
False,
enable_grouping=False,
fail_on_no_tests=True,
)
assert rc == 1
assert "No components found matching" in capsys.readouterr().out
File diff suppressed because it is too large Load Diff
-615
View File
@@ -4,9 +4,7 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
import pytest
@@ -490,616 +488,3 @@ def test_get_component_cmakelists_no_compile_features() -> None:
content = get_component_cmakelists()
assert "target_compile_features" not in content
@pytest.fixture(autouse=True)
def _pch_default_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the knob so a developer's ESPHOME_PCH_ENABLE=0 cannot fail these."""
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
def _make_pch_device(tmp_path: Path, name: str) -> Path:
"""A device dir with the pch source headers and a stub compile_commands."""
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
dev = tmp_path / name
for header in PCH_DEFAULT_HEADERS:
path = dev / "src" / header
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
# A real quoted include chain and a per-device-named sdkconfig with
# identical content: the closure and sdkconfig inputs must be exercised
(dev / "src" / "esphome" / "core" / "defines.h").write_text(
'#include "esphome/core/macros.h"\n'
)
(dev / "src" / "esphome" / "core" / "macros.h").write_text("#define M 1\n")
# Both spellings: tests patch CORE.name to "test" or to the device name
(dev / f"sdkconfig.{name}").write_text("CONFIG_X=y\n")
(dev / "sdkconfig.test").write_text("CONFIG_X=y\n")
build = dev / "build"
build.mkdir(exist_ok=True)
from esphome.build_helpers.pch import pch_header_text
(build / "esphome_pch.h").write_text(pch_header_text(PCH_DEFAULT_HEADERS))
# Native separators: mixed f-string paths break the src-prefix match
# on Windows
src_file = str(dev / "src" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps(
[
{
"directory": str(build),
"command": (
"g++ -DX=1 -include esphome_pch.h "
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
f'-c "{src_file}"'
),
"file": src_file,
}
]
)
)
return dev
def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_a")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
# The compile must target the header, not the stub TU
assert cmd[-5:-3] == ["c++-header", "-c"]
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
checksum = (dev / "build" / "esphome_pch.h.gch.sum").read_text().strip()
assert len(checksum) == 64
# Unchanged inputs: the second call must not recompile
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
def test_pch_no_device_path_poison(tmp_path: Path) -> None:
"""Regression: neither the injected -include nor the .sum may carry the
per-device build path, or cross-device ccache sharing breaks."""
from esphome.build_gen.espidf import get_component_cmakelists, prepare_pch
sums = []
for name in ("dev_a", "dev_b"):
dev = _make_pch_device(tmp_path, name)
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, _gch=gch, **kwargs):
_gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", name),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
content = get_component_cmakelists()
assert str(dev) not in content
sums.append((dev / "build" / "esphome_pch.h.gch.sum").read_text())
assert sums[0] == sums[1]
def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None:
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "-include" not in get_component_cmakelists()
def test_pch_compile_command_variants(tmp_path: Path) -> None:
"""Missing DB, no matching entry, and launcher-prefixed commands."""
from esphome.build_helpers.pch import pch_compile_command
build = tmp_path / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
assert pch_compile_command(build, header, gch) is None
(build / "compile_commands.json").write_text(
json.dumps(
[
{"command": "gcc -c other.c", "file": "other.c"},
]
)
)
assert pch_compile_command(build, header, gch) is None
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps(
[
{
"command": (
"/usr/bin/ccache g++ -DX=1 -include esphome_pch.h -MMD "
"-MT a.cpp.obj -MF a.cpp.obj.d "
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
f"-c {src_file}"
),
"file": src_file,
},
]
)
)
# Launcher stripped; -include/-o/-c and depfile flags removed
cmd, cmd_dir = pch_compile_command(build, header, gch)
assert cmd == [
"g++",
"-DX=1",
"-x",
"c++-header",
"-c",
str(header),
"-o",
str(gch),
]
# The compile must run where the flags were resolved
assert cmd_dir == build
@pytest.mark.skipif(os.name == "nt", reason="symlinks need privileges on Windows")
def test_pch_compile_command_matches_src_through_symlink(tmp_path: Path) -> None:
"""Find the src TU when CMake spells paths through a different symlink (macOS /tmp)."""
from esphome.build_helpers.pch import pch_compile_command
real = tmp_path / "real"
(real / "src" / "esphome").mkdir(parents=True)
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
CORE.build_path = str(real)
build = real / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
src_file = str(link / "src" / "esphome" / "a.cpp")
(build / "compile_commands.json").write_text(
json.dumps([{"command": f"g++ -DX=1 -o a.obj -c {src_file}", "file": src_file}])
)
cmd, cmd_dir = pch_compile_command(build, header, gch)
assert cmd[:2] == ["g++", "-DX=1"]
assert cmd_dir == build
def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None:
"""Malformed DB shapes and command-less entries skip cleanly instead of
producing a compiler-less argv retried every build."""
from esphome.build_helpers.pch import pch_compile_command
build = tmp_path / "build"
build.mkdir()
header = build / "esphome_pch.h"
gch = build / "esphome_pch.h.gch"
db = build / "compile_commands.json"
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
db.write_text(json.dumps({"not": "a list"}))
assert pch_compile_command(build, header, gch) is None
db.write_text(json.dumps(["just a string"]))
assert pch_compile_command(build, header, gch) is None
# An empty-string directory must fall back to the build dir, not cwd
db.write_text(
json.dumps(
[
{
"directory": "",
"command": f"g++ -DX=1 -o a.obj -c {src_file}",
"file": src_file,
}
]
)
)
_, cmd_dir = pch_compile_command(build, header, gch)
assert cmd_dir == build
# Corrupted entries with null fields must skip, not raise
db.write_text(
json.dumps(
[
{"file": None, "command": "g++ -c x.cpp", "directory": None},
{"file": src_file, "command": None, "directory": None},
]
)
)
assert pch_compile_command(build, header, gch) is None
# arguments-style entry (allowed by the spec, unused by CMake)
db.write_text(
json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}])
)
assert pch_compile_command(build, header, gch) is None
def test_pch_header_list_order_is_in_checksum(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Reordering PCH_DEFAULT_HEADERS keeps the include closure identical, but the
generated header text differs, so the .gch must rebuild."""
import esphome.build_gen.espidf as espidf_mod
dev = _make_pch_device(tmp_path, "dev_r")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
espidf_mod.prepare_pch()
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
monkeypatch.setattr(
espidf_mod,
"PCH_DEFAULT_HEADERS",
tuple(reversed(espidf_mod.PCH_DEFAULT_HEADERS)),
)
espidf_mod.prepare_pch()
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_f")
CORE.build_path = dev
calls = []
def failing_compile(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 1, "", "boom")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=failing_compile),
):
prepare_pch()
prepare_pch()
assert len(calls) == 1
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None:
"""Spawn/IO failures retry on the next build instead of latching."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_o")
CORE.build_path = dev
calls = []
def raising(cmd, **kwargs):
calls.append(cmd)
raise OSError("no such compiler")
header = dev / "build" / "esphome_pch.h"
before = header.stat().st_mtime_ns
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=raising),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert len(calls) == 2
# No .gch was ever in play, so the header must not be re-touched into
# forcing a full rebuild on every failing build
assert header.stat().st_mtime_ns == before
def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> None:
"""A stale .gch removed on a transient failure must dirty its consumers."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_s")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
gch.write_bytes(b"stale")
header = dev / "build" / "esphome_pch.h"
os.utime(header, (1, 1))
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.subprocess.run",
side_effect=OSError("no such compiler"),
),
):
prepare_pch()
assert not gch.exists()
assert header.stat().st_mtime_ns > 1_000_000_000
def test_prepare_pch_disabled_discards_and_skips_compile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The escape hatch is self-cleaning: a leftover .gch is removed."""
from esphome.build_gen.espidf import prepare_pch
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
dev = _make_pch_device(tmp_path, "dev_d")
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError):
prepare_pch()
assert not stale.exists()
def test_prepare_pch_missing_sdkconfig_fails_closed(tmp_path: Path) -> None:
"""No sdkconfig means no config identity for the .sum: no pch at all."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_m")
(dev / "sdkconfig.test").unlink()
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not stale.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
"""A signal-killed compile (OOM) must not latch the .failed marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_k")
CORE.build_path = dev
calls = []
def killed(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, -9, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=killed),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None:
"""Stale checksum but no configured TU yet: no compile, no sidecars."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_n")
(dev / "build" / "compile_commands.json").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_write_project_pch_disabled_writes_no_header(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import write_project
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
_write_project_description(tmp_path, {})
CORE.build_path = tmp_path
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
write_project()
assert not (tmp_path / "build" / "esphome_pch.h").exists()
def test_write_project_writes_pch_header(tmp_path: Path) -> None:
"""The header write_project emits is what _pch_cmake() force-includes;
this pairing is the one non-fail-safe path in the design."""
from esphome.build_gen.espidf import write_project
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_header_text
_write_project_description(tmp_path, {})
CORE.build_path = tmp_path
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
write_project()
assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text(
PCH_DEFAULT_HEADERS
)
def test_prepare_pch_stale_bailout_removes_gch(tmp_path: Path) -> None:
"""A stale .gch must not survive when no compile command is available."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_s")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
gch.write_bytes(b"stale")
(dev / "build" / "esphome_pch.h.gch.sum").write_text("stale-sum\n")
(dev / "build" / "compile_commands.json").unlink()
with patch.object(CORE, "name", "test"):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None:
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_z")
CORE.build_path = dev
def no_output(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=no_output),
):
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None:
"""The OBJECT_DEPENDS edge watches the header; a rebuilt .gch must bump
it so pch-consuming TUs recompile."""
import os as _os
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_t")
CORE.build_path = dev
header = dev / "build" / "esphome_pch.h"
gch = dev / "build" / "esphome_pch.h.gch"
_os.utime(header, (0, 0))
before = header.stat().st_mtime
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
assert header.stat().st_mtime > before
def test_component_cmakelists_pch_object_depends() -> None:
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content
def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None:
"""A flag-only change in the compile DB must rebuild the .gch."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_c")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
gch.write_bytes(b"gch")
return subprocess.CompletedProcess(cmd, 0, "", "")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
):
prepare_pch()
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
db = dev / "build" / "compile_commands.json"
db.write_text(db.read_text().replace("-DX=1", "-DX=2"))
prepare_pch()
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None:
from esphome.build_helpers.pch import pch_compile_command
dev = _make_pch_device(tmp_path, "dev_u")
CORE.build_path = dev
build = dev / "build"
src_file = str(dev / "src" / "esphome" / "a.cpp")
build.joinpath("compile_commands.json").write_text(
json.dumps(
[
{
"directory": str(build),
"command": (
"g++ -include user.h -include esphome_pch.h "
f"-o a.obj -c {src_file}"
),
"file": src_file,
}
]
)
)
cmd, _ = pch_compile_command(build, build / "esphome_pch.h", build / "x.gch")
assert "user.h" in cmd
assert "esphome_pch.h" not in " ".join(cmd[:-3])
def test_prepare_pch_identity_unknown_discards(tmp_path: Path) -> None:
"""An OSError from the checksum discards artifacts and skips the pch."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_i")
CORE.build_path = dev
stale = dev / "build" / "esphome_pch.h.gch"
stale.write_bytes(b"stale")
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.pch_checksum",
side_effect=OSError("stat failed"),
),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
):
prepare_pch()
assert not stale.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
def test_prepare_pch_transient_compiler_failure_does_not_latch(
tmp_path: Path,
) -> None:
"""ENOSPC-style failures clear on their own; no .failed marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_e")
CORE.build_path = dev
calls = []
def enospc(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(
cmd, 1, "", "fatal error: No space left on device"
)
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=enospc),
):
prepare_pch()
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
@@ -10,7 +10,6 @@ from unittest.mock import patch
import pytest
from esphome.build_helpers import ccache
from esphome.core import CORE
def test_resolve_opt_out() -> None:
@@ -121,17 +120,3 @@ def test_parse_enable_env_spelling_tables(
"""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())
+7 -88
View File
@@ -85,87 +85,6 @@ def test_parse_entry_resolves_relative_includes() -> None:
assert all(Path(inc).is_absolute() for inc in includes)
def test_parse_entry_resolves_force_include_path(tmp_path: Path) -> None:
"""The pch -include is emitted relative to the build dir; idedata must
resolve it so cached flags work from any cwd."""
(tmp_path / "esphome_pch.h").write_text("")
entry = _entry(
str(tmp_path),
f"{tmp_path}/src/esphome/x.cpp",
"g++ -include esphome_pch.h -c x.cpp",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
idx = cxx_flags.index("-include")
resolved = cxx_flags[idx + 1]
assert Path(resolved).is_absolute()
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
def test_parse_entry_resolves_joined_force_include(tmp_path: Path) -> None:
"""The joined -includefoo.h spelling takes the same resolve path."""
(tmp_path / "esphome_pch.h").write_text("")
entry = _entry(
str(tmp_path),
f"{tmp_path}/src/esphome/x.cpp",
"g++ -includeesphome_pch.h -c x.cpp",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
resolved = cxx_flags[cxx_flags.index("-include") + 1]
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None:
"""-include names resolved via the -I chain (libretiny's Arduino.h) must
not be re-anchored to a nonexistent build-dir path."""
entry = _entry(
str(tmp_path),
f"{tmp_path}/src/esphome/x.cpp",
"g++ -include Arduino.h -c x.cpp",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h"
def test_parse_entry_warns_on_vanished_force_include(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A build-dir force-include deleted by clean_build must leave a trail;
a name resolvable via the -I chain must not warn."""
inc = tmp_path / "inc"
inc.mkdir()
(inc / "Arduino.h").write_text("")
entry = _entry(
str(tmp_path),
f"{tmp_path}/src/esphome/x.cpp",
f"g++ -I{inc} -include Arduino.h -include esphome_pch.h -c x.cpp",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert "Arduino.h" in cxx_flags
assert "esphome_pch.h" in caplog.text
assert "Arduino.h" not in caplog.text
def test_parse_entry_drops_trailing_force_include(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
entry = _entry(
str(tmp_path), f"{tmp_path}/src/esphome/x.cpp", "g++ -c x.cpp -include"
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert "-include" not in cxx_flags
assert "no argument" in caplog.text
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
@@ -185,7 +104,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata.expand_response_files(
tokens = idedata._expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
@@ -196,7 +115,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata.expand_response_files(["g++", "@nope.rsp"], tmp_path)
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
@@ -408,7 +327,7 @@ def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata.split_command(command)
tokens = idedata._split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
@@ -422,8 +341,8 @@ def test_split_command_empty_returns_empty() -> None:
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata.split_command("") == []
assert idedata.split_command(" ") == []
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
@@ -584,9 +503,9 @@ def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
def test_is_launcher_matches_only_known_launchers() -> None:
"""Compilers of any shape pass; only the closed launcher set matches."""
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
assert not idedata.is_launcher(token)
assert not idedata._is_launcher(token)
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
assert idedata.is_launcher(token)
assert idedata._is_launcher(token)
def test_load_or_build_idedata_corrupted_cache_is_logged(
-218
View File
@@ -1,218 +0,0 @@
"""Tests for esphome.build_helpers.pch."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.build_helpers import pch
def _write(src_dir: Path, name: str, content: str) -> None:
path = src_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
@pytest.mark.parametrize(
("value", "expected"),
[
(None, True),
("1", True),
("0", False),
("false", False),
("", False),
],
)
def test_pch_enabled(value: str | None, expected: bool) -> None:
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
with patch.dict(os.environ, env, clear=True):
assert pch.pch_enabled() is expected
def test_ccache_pch_env_empty_until_emitted() -> None:
"""No sloppiness relaxation for a build that skipped the pch."""
with patch.dict(os.environ, {}, clear=True):
assert pch.ccache_pch_env() == {}
def test_ccache_pch_env_enabled() -> None:
pch.mark_pch_emitted()
with patch.dict(os.environ, {}, clear=True):
env = pch.ccache_pch_env()
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_pch_env_disabled() -> None:
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
assert pch.ccache_pch_env() == {}
def test_ccache_pch_env_token_check_is_membership_not_substring(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A token merely containing ours must not suppress the union."""
pch.mark_pch_emitted()
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "pch_defines_extra"}, clear=True):
env = pch.ccache_pch_env()
assert env["CCACHE_SLOPPINESS"] == "pch_defines_extra,pch_defines,time_macros"
def test_ccache_pch_env_unions_user_sloppiness(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without pch_defines/time_macros ccache declines every pch-consuming
compile, so missing tokens are unioned onto the user's value."""
pch.mark_pch_emitted()
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
env = pch.ccache_pch_env()
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
assert "Adding pch_defines,time_macros" in caplog.text
caplog.clear()
with patch.dict(
os.environ, {"CCACHE_SLOPPINESS": "pch_defines,time_macros"}, clear=True
):
env = pch.ccache_pch_env()
assert "CCACHE_SLOPPINESS" not in env
assert not caplog.records
def test_pch_header_text_preserves_order() -> None:
text = pch.pch_header_text(["b.h", "a.h"])
assert text == '#include "b.h"\n#include "a.h"\n'
def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None:
"""Sibling includes resolve against the includer's directory first,
full paths against the src root; unresolvable names end the walk."""
_write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n')
_write(
tmp_path,
"esphome/components/x/b.h",
'#include "esphome/core/deep.h"\n#include <system.h>\n#include "missing.h"\n',
)
_write(tmp_path, "esphome/core/deep.h", "")
closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"])
assert sorted(closure) == [
"esphome/components/x/a.h",
"esphome/components/x/b.h",
"esphome/core/deep.h",
]
def test_include_closure_handles_cycles(tmp_path: Path) -> None:
_write(tmp_path, "a.h", '#include "b.h"\n')
_write(tmp_path, "b.h", '#include "a.h"\n')
assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"]
def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None:
_write(tmp_path / "src", "a.h", '#include "../outside.h"\n')
(tmp_path / "outside.h").write_text("")
assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"]
def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None:
"""A transitive header edit or an extra-identity change must change the
digest; unrelated files must not."""
_write(tmp_path, "root.h", '#include "nested.h"\n')
_write(tmp_path, "nested.h", "int a;\n")
_write(tmp_path, "unrelated.h", "int u;\n")
base = pch.pch_checksum(tmp_path, ["root.h"], ["id"])
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"])
_write(tmp_path, "unrelated.h", "int changed;\n")
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
_write(tmp_path, "nested.h", "int b;\n")
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"])
@pytest.mark.skipif(
os.name == "nt" or os.geteuid() == 0, reason="chmod is ineffective here"
)
def test_include_closure_fails_closed_on_unreadable(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A marker would truncate the transitive walk; the OSError propagates
so callers compile without a pch."""
_write(tmp_path, "a.h", '#include "locked.h"\n')
locked = tmp_path / "locked.h"
locked.write_text("")
locked.chmod(0)
try:
with pytest.raises(OSError):
pch._include_closure(tmp_path, ["a.h"])
finally:
locked.chmod(0o644)
assert "Could not read locked.h" in caplog.text
def test_pch_extra_scripts_gated(monkeypatch: pytest.MonkeyPatch) -> None:
with patch.dict(os.environ, {}, clear=True):
assert pch.pch_extra_scripts() == ["post:pch.py"]
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert pch.pch_extra_scripts() == []
def test_include_closure_raises_when_identity_unknown(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unreadable header propagates; callers compile without a pch."""
class _BadFile:
def stat(self): # noqa: ANN202 -- regular-file mode only
import os
import stat as stat_mod
return os.stat_result((stat_mod.S_IFREG | 0o644,) + (0,) * 9)
def read_bytes(self) -> bytes:
raise OSError("read failed")
class _FakeSrcDir:
def __truediv__(self, rel: str) -> _BadFile:
return _BadFile()
with pytest.raises(OSError, match="read failed"):
pch._include_closure(_FakeSrcDir(), ["a.h"])
assert "Could not read a.h" in caplog.text
def test_include_closure_survives_non_utf8_include_name(tmp_path: Path) -> None:
"""A non-UTF-8 quoted include must not abort the build; it simply does
not resolve and ends the walk."""
(tmp_path / "a.h").write_bytes(b'#include "bad\xff.h"\n#include "b.h"\n')
(tmp_path / "b.h").write_text("")
closure = pch._include_closure(tmp_path, ["a.h"])
assert set(closure) == {"a.h", "b.h"}
def test_pch_checksum_survives_surrogate_extra(tmp_path: Path) -> None:
"""Install paths from non-UTF-8 filesystems carry surrogates; hashing
them must not raise past the caller's identity-unknown guard."""
assert pch.pch_checksum(tmp_path, [], ["/opt/bad\udcff/framework"])
def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None:
"""An angle include resolving under src/ must enter the digest; one
that does not simply ends the walk."""
_write(tmp_path, "a.h", "#include <local.h>\n#include <Arduino.h>\n")
(tmp_path / "local.h").write_text("")
closure = pch._include_closure(tmp_path, ["a.h"])
assert set(closure) == {"a.h", "local.h"}
def test_ccache_pch_env_warns_on_falsy_extsum(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A user CCACHE_PCH_EXTSUM=false makes ccache hash the .gch bytes."""
pch.mark_pch_emitted()
with patch.dict(os.environ, {"CCACHE_PCH_EXTSUM": "false"}, clear=True):
env = pch.ccache_pch_env()
assert "CCACHE_PCH_EXTSUM" not in env
assert "disables pch caching" in caplog.text
@@ -1,193 +0,0 @@
"""Tests for the native (non-PlatformIO) toolchain config validation."""
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.components import esp8266
from esphome.components.esp8266 import (
ARDUINO_FRAMEWORK_SCHEMA,
_resolve_toolchain,
_validate_native_toolchain,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
CONF_TOOLCHAIN,
CONF_VERSION,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.types import ConfigType
@pytest.fixture(autouse=True)
def _arduino_toolchain() -> Generator[None]:
# The suite-wide reset_core fixture clears CORE.toolchain after each test
CORE.toolchain = Toolchain.ARDUINO
esp8266._DECODE_WARNED_AT.clear()
yield
esp8266._DECODE_WARNED_AT.clear()
def _config(
board: str = "nodemcuv2",
version: str = "3.1.2",
source: str | None = None,
platform_version: str | None = None,
) -> ConfigType:
framework: dict[str, str] = {CONF_VERSION: version}
if source is not None:
framework[CONF_SOURCE] = source
if platform_version is not None:
framework[CONF_PLATFORM_VERSION] = platform_version
# The real schema fills the source/platform_version defaults, so these
# tests validate against what config validation actually emits
return {
CONF_FRAMEWORK: ARDUINO_FRAMEWORK_SCHEMA(framework),
CONF_BOARD: board,
}
def test_valid_config_passes() -> None:
config = _config()
assert _validate_native_toolchain(config) is config
def test_platformio_toolchain_skips_checks() -> None:
# 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native
# toolchain's own 3.1.1 floor; the bogus board only the native path checks
CORE.toolchain = Toolchain.PLATFORMIO
config = _config(board="not_a_board", version="3.0.2")
assert _validate_native_toolchain(config) is config
def test_version_below_floor_rejected() -> None:
# 3.1.0 has no registry package, so the native floor is 3.1.1
with pytest.raises(cv.Invalid, match="3.1.1 or newer"):
_validate_native_toolchain(_config(version="3.1.0"))
def test_version_at_floor_accepted() -> None:
_validate_native_toolchain(_config(version="3.1.1"))
def test_custom_platform_version_warns_and_is_dropped(
caplog: pytest.LogCaptureFixture,
) -> None:
config = _config(platform_version="platformio/espressif8266@4.0.1")
_validate_native_toolchain(config)
assert "'platform_version' is ignored" in caplog.text
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
def test_default_platform_version_does_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
config = _config()
_validate_native_toolchain(config)
assert "'platform_version' is ignored" not in caplog.text
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
def test_custom_source_rejected() -> None:
with pytest.raises(cv.Invalid, match="custom framework source"):
_validate_native_toolchain(
_config(source="https://github.com/esp8266/Arduino.git")
)
def test_unsupported_board_rejected() -> None:
with pytest.raises(cv.Invalid, match="not supported by"):
_validate_native_toolchain(_config(board="not_a_board"))
def test_yaml_toolchain_key_resolves() -> None:
"""The documented `toolchain: arduino` YAML key selects the native path."""
CORE.toolchain = None
_resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO})
assert CORE.toolchain == Toolchain.ARDUINO
assert CORE.using_toolchain_arduino
def test_yaml_toolchain_key_defaults_to_platformio() -> None:
CORE.toolchain = None
_resolve_toolchain({})
assert CORE.toolchain == Toolchain.PLATFORMIO
def test_decode_pc_native_missing_tools_warns_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A stack dump of many addresses produces one missing-tool warning."""
with (
patch(
"esphome.arduino8266.toolchain.get_addr2line_path",
return_value=tmp_path / "missing-addr2line",
),
patch(
"esphome.arduino8266.toolchain.get_elf_path",
return_value=tmp_path / "missing.elf",
),
):
esp8266._decode_pc({}, "40201234")
esp8266._decode_pc({}, "40201238")
assert caplog.text.count("Cannot decode crash addresses") == 1
def test_decode_pc_platformio_missing_tools_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The PlatformIO branch reports a missing addr2line/ELF at the same
warning level as the native one; raw undecoded addresses with no
stated reason are undiagnosable at default log level."""
CORE.toolchain = Toolchain.PLATFORMIO
idedata = SimpleNamespace(addr2line_path=None, firmware_elf_path=None)
with patch("esphome.platformio.toolchain.get_idedata", return_value=idedata):
esp8266._decode_pc({}, "40201234")
esp8266._decode_pc({}, "40201238")
assert caplog.text.count("Cannot decode crash addresses") == 1
def test_resolve_toolchain_rejects_unsupported() -> None:
"""ESP8266 rejects a CLI toolchain it cannot serve, like every platform."""
CORE.toolchain = Toolchain.SDK_NRF
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"):
_resolve_toolchain({})
def test_run_compile_platformio_falls_through() -> None:
"""Under toolchain: platformio the hook returns False without touching
the native backend; this is what keeps existing users on PlatformIO."""
CORE.toolchain = Toolchain.PLATFORMIO
with patch("esphome.arduino8266.toolchain.run_compile") as mock_native:
assert esp8266.run_compile(SimpleNamespace(), {}) is False
mock_native.assert_not_called()
def test_run_compile_arduino_failure_raises() -> None:
"""A non-zero native build fails by name instead of returning success."""
CORE.verbose = False
with (
patch("esphome.arduino8266.toolchain.run_compile", return_value=1),
pytest.raises(EsphomeError, match="native build failed"),
):
esp8266.run_compile(SimpleNamespace(), {})
def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None:
"""The native build writes no PlatformIO extra scripts."""
CORE.build_path = tmp_path
esp8266.copy_files()
assert list(tmp_path.iterdir()) == []
+9
View File
@@ -1429,3 +1429,12 @@ async def test_add_platformio_options_native_arduino(
assert "board_build.ldscript is ignored" in caplog.text
assert "'arduino' toolchain" in caplog.text
assert "upload_speed" not in caplog.text
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
from esphome.components.esp8266 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"board": "nodemcuv2"})
@@ -1,65 +0,0 @@
"""Run the native-toolchain serial-upload path and report heavy modules.
Executed as a subprocess by test_lazy_imports.py: heavy module names come
in on argv, the ones found in sys.modules afterwards go out on stdout.
``upload_using_esptool`` dispatches native toolchains through a
toolchain-keyed table; if someone routes it back through the platform
component packages (esp32 or esp8266), this reports the leak.
"""
import os
from pathlib import Path
import sys
import tempfile
from unittest.mock import patch
from _leak_report import print_leaked_modules
from esphome.__main__ import upload_using_esptool
from esphome.const import (
CONF_ESPHOME,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
Toolchain,
)
from esphome.core import CORE
# An ambient ESPHOME_USE_SUBPROCESS would route past the patched
# run_external_command into run_external_process and confuse the checks.
os.environ.pop("ESPHOME_USE_SUBPROCESS", None)
config = {CONF_ESPHOME: {"platformio_options": {}}}
with tempfile.TemporaryDirectory() as build_dir:
CORE.name = "leaktest"
CORE.build_path = build_dir
for platform, toolchain, backend in (
("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
):
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if platform == "esp32":
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
CORE.toolchain = toolchain
import importlib
image = importlib.import_module(backend).get_factory_firmware_path()
image.parent.mkdir(parents=True, exist_ok=True)
image.write_bytes(b"\x00")
with patch("esphome.__main__.run_external_command", return_value=0) as mock_run:
rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
# Fail loudly if the upload path stopped doing its work; otherwise
# an empty leak list could just mean nothing ran.
if rc != 0:
sys.exit(f"upload_using_esptool({platform}) returned {rc}")
cmd = list(mock_run.call_args[0][1:])
if str(image) not in [str(Path(c)) for c in cmd]:
sys.exit(f"native factory image did not reach esptool: {cmd}")
print_leaked_modules()
@@ -1,657 +0,0 @@
"""Tests for esphome.arduino8266.toolchain (the ninja build driver)."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import framework, toolchain
from esphome.build_helpers.pch import mark_pch_emitted
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE, EsphomeError
_SIZE_OUTPUT = """\
firmware.elf :
section size addr
.data 1924 1073643520
.noinit 56 1073645444
.text 496 1074790400
.irom0.text 342804 1075843088
.text1 27489 1074790896
.rodata 2588 1073645504
.bss 26504 1073648096
Total 401861
"""
@pytest.fixture(autouse=True)
def _setup_core(tmp_path: Path) -> None:
CORE.name = "test8266"
CORE.config_path = tmp_path / "test8266.yaml"
CORE.build_path = tmp_path
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
# run_compile verifies the produced artifacts; give every test a build
# that "produced" them (tests for the guard delete them again). The
# manifest comes first: artifacts must not be older than build.ninja.
build_dir = CORE.relative_pioenvs_path("test8266")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("# manifest")
for artifact in (
"firmware.elf",
"firmware.bin",
"firmware.factory.bin",
"firmware.ota.bin",
):
(build_dir / artifact).write_bytes(b"")
def _paths(tmp_path: Path) -> framework.InstalledPaths:
return framework.InstalledPaths(
framework=tmp_path / "framework",
toolchain=tmp_path / "toolchain",
ninja=tmp_path / "ninja",
)
def test_path_getters(tmp_path: Path) -> None:
assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266")
assert toolchain.get_elf_path().name == "firmware.elf"
# The framework accessor owns the layout and the Windows suffix
suffix = ".exe" if os.name == "nt" else ""
assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}"
assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}"
assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}"
def test_run_compile_build_failure(tmp_path: Path) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
cmd = mock_run.call_args[0][0]
assert "-v" in cmd
# The compile database is generated before the build runs, so a failed
# build cannot leave a stale database behind.
mock_compdb.assert_called_once()
def test_run_compile_success(tmp_path: Path) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
# An unchanged manifest is what makes the -n probe run
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata") as mock_idedata,
):
rc = toolchain.run_compile(
{CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False
)
assert rc == 0
# The -n probe runs first, then the real build (cwd, no -C banner)
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
assert "-n" in ninja_calls[0][0][0]
# Explicit targets: a manifest missing them fails as "unknown target"
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
cmd = ninja_calls[1][0][0]
assert cmd[-4:] == ["-j", "4", "firmware.factory.bin", "firmware.ota.bin"]
assert "-C" not in cmd
assert ninja_calls[1][1]["cwd"] is not None
mock_compdb.assert_called_once()
mock_size.assert_called_once()
mock_idedata.assert_called_once()
def test_run_compile_noop_skips_the_build_spawn(tmp_path: Path) -> None:
"""A no-op rebuild stays quiet: the -n probe answers "no work to do"
and the real ninja spawn (and its banner) never happens."""
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
# An unchanged manifest is what makes the -n probe run
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(
returncode=0, stdout="ninja: no work to do.\n", stderr=""
),
) as mock_run,
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
assert rc == 0
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
assert len(ninja_calls) == 1
assert "-n" in ninja_calls[0][0][0]
# Explicit targets: a manifest missing them fails as "unknown target"
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
"""An interrupted run can leave build.ninja newer than the compile DB;
mere existence must not skip regeneration."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("")
compdb = build_dir / "compile_commands.json"
compdb.write_text("[]")
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(
returncode=0, stdout="ninja: no work to do.\n", stderr=""
),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_compdb.assert_called_once()
def test_run_compile_surfaces_probe_diagnostics(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A load-time ninja diagnostic (a generator bug signal) reaches the
user even when the no-work branch skips the real spawn."""
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
# An unchanged manifest is what makes the -n probe run
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(
returncode=0,
stdout="ninja: no work to do.\n",
stderr="ninja: warning: multiple rules generate x\n",
),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
assert rc == 0
assert "multiple rules generate x" in caplog.text
def test_run_compile_missing_artifact_fails(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A zero ninja exit that produced no firmware must not be a green
build (size summary and idedata only warn)."""
(toolchain.get_build_dir() / "firmware.elf").unlink()
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata"),
):
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
assert rc == 1
assert "Build produced no" in caplog.text
mock_size.assert_not_called()
def test_run_compile_warns_when_idedata_fails(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed idedata generation right after a successful build is visible,
not deferred to a misleading error in a later command."""
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata", return_value=None),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "No idedata was generated for this build" in caplog.text
def test_write_compile_commands(tmp_path: Path) -> None:
build_dir = tmp_path / "build"
build_dir.mkdir()
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=entries),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert (build_dir / "compile_commands.json").read_text() == entries
@pytest.mark.parametrize(
("stdout", "match"),
[
("[]\n", "empty compile database"),
# A parse failure names its cause, not the rule-name story
("not json", "unparsable compile database.*not json"),
],
)
def test_write_compile_commands_bad_db_raises(
tmp_path: Path, stdout: str, match: str
) -> None:
"""An empty or unparsable compile database fails the build with its
actual cause and drops any stale database."""
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[stale]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=stdout),
),
pytest.raises(EsphomeError, match=match),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert not (build_dir / "compile_commands.json").exists()
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
"""A failed compdb run must not leave a stale database behind."""
stale = tmp_path / "compile_commands.json"
stale.write_text("[]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stderr="boom"),
),
pytest.raises(EsphomeError, match="compile_commands"),
):
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
assert not stale.exists()
def test_parse_app_size(tmp_path: Path) -> None:
ld = tmp_path / "eagle.flash.4m.ld"
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) == 0xFEFF0
ld.write_text("MEMORY { }\n")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
# A zero-length segment is bad data, not a budget; warn and drop it
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
with patch(
"esphome.build_gen.arduino8266.get_flash_ld_path",
return_value=tmp_path / "missing.ld",
):
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
),
patch.object(toolchain, "_parse_app_size", return_value=1044464),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
out = capsys.readouterr().out
# Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it
assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out
assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out
def test_print_size_summary_missing_size_tool_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A missing size binary degrades to a warning; the firmware already
linked and must not be discarded."""
with patch.object(
toolchain.subprocess, "run", side_effect=FileNotFoundError("no size")
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
assert "Could not summarize firmware size" in caplog.text
def test_print_size_summary_no_app_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
),
patch.object(toolchain, "_parse_app_size", return_value=None),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
out = capsys.readouterr().out
# Both lines are skipped together: a RAM line without Flash would skew
# CI's memory-impact sums across builds
assert out == ""
def test_print_size_summary_size_tool_failure(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stdout="", stderr="bad elf"),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
assert capsys.readouterr().out == ""
assert "Could not summarize firmware size" in caplog.text
def test_get_idedata_delegates(tmp_path: Path) -> None:
with (
patch(
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"cc_path": "x"},
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
):
assert toolchain.get_idedata() == {"cc_path": "x"}
compile_commands, elf, cache = mock_load.call_args[0]
assert compile_commands.name == "compile_commands.json"
assert elf.name == "firmware.elf"
assert cache.name == "test8266.arduino.json"
# The exact configured launcher string is passed for compile DB parsing
# (resolve_ccache_path returns a str, untouched on every platform)
assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache"
def test_get_idedata_no_ccache(tmp_path: Path) -> None:
with (
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value=None),
):
toolchain.get_idedata()
assert mock_load.call_args.kwargs["launcher"] is None
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
"""An unchanged build.ninja means the compile DB is already current."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
# write_project (stubbed below) always leaves a build.ninja behind
(build_dir / "build.ninja").write_text("# manifest")
def run(regenerate_expected: bool) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert mock_compdb.called == regenerate_expected
# Missing compile DB: regenerated even though build.ninja is unchanged
run(regenerate_expected=True)
# Present compile DB + unchanged build.ninja: skipped
(build_dir / "compile_commands.json").write_text("[]")
run(regenerate_expected=False)
def test_print_size_summary_unparsable_section(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A totals-relevant section that fails to parse must not produce a
confident wrong number; an irrelevant one only warns."""
bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc")
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=bad),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
assert capsys.readouterr().out == ""
assert "Unparsable size output" in caplog.text
caplog.clear()
harmless = _SIZE_OUTPUT + ".broken abc 0\n"
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=harmless),
),
patch.object(toolchain, "_parse_app_size", return_value=1044464),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
assert "RAM:" in capsys.readouterr().out
assert "Unparsable size output" in caplog.text
def test_print_size_summary_missing_section_skips_summary(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A totals section absent from the output must not default to zero."""
without_bss = "\n".join(
line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line
)
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=without_bss),
):
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
assert capsys.readouterr().out == ""
assert "missing section(s) .bss" in caplog.text
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
"""Component-added options the native build drops are warned by name;
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
flash_mode) stay quiet."""
CORE.platformio_options = {
"board_build.ldscript": "eagle.flash.4m2m.ld",
"board_build.f_cpu": "160000000L",
"board_build.filesystem": "littlefs",
"board_build.flash_mode": "dio",
"build_src_flags": "-include throw_stubs.h",
"lib_ignore": ["Updater"],
"upload_speed": "460800",
}
toolchain._warn_ignored_platformio_options()
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_build.f_cpu is ignored" not in caplog.text
assert "lib_ignore" not in caplog.text
assert "build_src_flags" not in caplog.text
assert "flash_mode" not in caplog.text
# Component-added upload_speed never gets read under the native
# toolchain, so it must warn
assert "upload_speed" in caplog.text
def test_run_compile_idedata_error_does_not_fail_build(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unusable compile DB after a successful build warns, never fails."""
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(
toolchain,
"get_idedata",
side_effect=EsphomeError("compile database is unusable"),
),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "Could not generate idedata: compile database is unusable" in caplog.text
def test_run_compile_skipped_size_summary_names_consequence(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A skipped RAM/Flash summary warns about the missing metric; the
per-cause warnings alone are invisible to CI harnesses."""
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project"),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary", return_value=False),
patch.object(toolchain, "get_idedata", return_value=None),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "Firmware size summary unavailable for this build" in caplog.text
def test_parse_app_size_non_utf8_ld_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A corrupt (non-UTF-8) linker script degrades to the same warning as
an unreadable one, never a traceback after a successful link."""
paths = _paths(tmp_path)
ld = tmp_path / "corrupt.ld"
ld.write_bytes(b"\xff\xfe not utf8")
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
assert toolchain._parse_app_size(tmp_path, paths) is None
assert "Cannot read linker script" in caplog.text
def test_run_compile_failed_probe_runs_full_build(tmp_path: Path) -> None:
"""A failing -n probe (e.g. unknown target from a defective manifest)
falls through to the real build so the error prints attributably."""
probe = MagicMock(returncode=1, stdout="", stderr="")
ok = MagicMock(returncode=0, stdout="", stderr="")
def fake_run(cmd, *args, **kwargs):
return probe if "-n" in cmd else ok
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(toolchain.subprocess, "run", side_effect=fake_run) as mock_run,
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "_print_size_summary", return_value=True),
patch.object(toolchain, "get_idedata", return_value={}),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
# The real build ran after the failed probe
assert any(
"firmware.ota.bin" in c[0][0] and "-n" not in c[0][0]
for c in mock_run.call_args_list
)
def test_get_idedata_accepts_preresolved_ccache() -> None:
"""run_compile threads its resolved ccache through; the probe must not
run again."""
with (
patch(
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"ok": True},
) as mock_build,
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
):
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
mock_resolve.assert_not_called()
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
def test_ccache_env_includes_pch_settings() -> None:
"""The native build exports the ccache settings the pch needs."""
mark_pch_emitted()
with patch.dict(os.environ, {}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_env_pch_disabled() -> None:
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
assert "CCACHE_SLOPPINESS" not in env
assert "CCACHE_PCH_EXTSUM" not in env
def test_ccache_env_respects_user_sloppiness() -> None:
mark_pch_emitted()
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
# The user's tokens survive; the ones the pch needs are unioned on
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
+2 -10
View File
@@ -19,7 +19,6 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers.pch import _PCHData
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
@@ -1593,14 +1592,10 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
# ccache_defaults_env and the pch emission flag read CORE at call time
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
patch(
"esphome.core.CORE",
SimpleNamespace(
build_path=build_path,
# Pre-marked: these env tests model a pch-emitting build
data={"pch": _PCHData(emitted=True)},
),
SimpleNamespace(build_path=build_path),
),
)
@@ -1614,9 +1609,6 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
assert env["CCACHE_NOHASHDIR"] == "true"
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
# The pch cannot cache under ccache without these
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
-33
View File
@@ -93,13 +93,6 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch)
assert toolchain._get_configured_targets() is None
@pytest.fixture(autouse=True)
def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Deterministic run_compile: no host ccache probe, no pch work."""
monkeypatch.setenv("IDF_CCACHE_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
CORE.name = "test"
@@ -667,29 +660,3 @@ def test_get_core_framework_version_from_core_data():
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
assert toolchain._get_core_framework_version() == "5.5.4"
def test_run_compile_invokes_prepare_pch_and_survives_failure(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The pch hook runs before the build and a failure never aborts it."""
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
_setup_build(setup_core)
# A stale .gch must be discarded on the failure path, never consumed
build = setup_core / "build" / "test" / "build"
build.mkdir(parents=True, exist_ok=True)
(build / "esphome_pch.h").write_text("")
stale_gch = build / "esphome_pch.h.gch"
stale_gch.write_bytes(b"stale")
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary"),
patch(
"esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")
) as prepare,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
prepare.assert_called_once()
assert not stale_gch.exists()
-21
View File
@@ -175,27 +175,6 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules(
)
def test_native_upload_fast_path_does_not_import_heavy_modules(
fixture_path: Path,
probe_env: dict[str, str],
) -> None:
"""The native-toolchain serial upload dispatches through a
toolchain-keyed backend table; resolving the flash image must not drag
in the esp32 or esp8266 component package or the validation stack.
"""
leaked = _leaked_from_fixture(
fixture_path,
probe_env,
"native_upload_fast_path.py",
extra=("esphome.components.esp8266",),
)
assert not leaked, (
f"native upload_using_esptool pulls in heavy modules: {leaked}. "
"The upload fast path skips validation; importing a platform "
"component package executes its codegen module by design."
)
def test_api_client_does_not_import_heavy_modules() -> None:
"""``esphome.api_client`` is on the logs fast path and must stay light.
+3 -262
View File
@@ -13,7 +13,7 @@ import sys
import time
from types import SimpleNamespace
from typing import Any, Self
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from pytest import CaptureFixture
@@ -25,7 +25,6 @@ from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_native_toolchain_module,
_redact_with_legacy_fallback,
_resolve_network_devices,
_should_subscribe_states,
@@ -7031,7 +7030,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
setup_core(platform=PLATFORM_ESP32)
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
@@ -7045,7 +7044,7 @@ def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
def test_command_idedata_esp_idf_no_build_errors() -> None:
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
setup_core(platform=PLATFORM_ESP32)
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
@@ -7219,201 +7218,6 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
assert not caplog.text
def test_upload_using_esptool_arduino_toolchain(
tmp_path: Path,
mock_run_external_command_main: Mock,
) -> None:
"""The native ESP8266 Arduino toolchain flashes its factory image at
0x0, resolved from the toolchain-keyed backend table (deliberately not
the platform hook: that import would break the upload fast path)."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
CORE.toolchain = Toolchain.ARDUINO
from esphome.arduino8266 import toolchain as native
factory = native.get_factory_firmware_path()
factory.parent.mkdir(parents=True, exist_ok=True)
factory.touch()
config = {CONF_ESPHOME: {"platformio_options": {}}}
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
assert result == 0
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
firmware_offset_idx = cmd_list.index("write-flash") + 4
assert cmd_list[firmware_offset_idx] == "0x0"
assert cmd_list[firmware_offset_idx + 1] == str(factory)
@pytest.mark.parametrize(
("toolchain", "pio_project_written"),
[
# The native toolchain generates its project at compile time, so
# write_cpp_file must not write a platformio.ini; the default
# toolchain writes the PlatformIO project files.
(Toolchain.ARDUINO, False),
(None, True),
],
)
def test_write_cpp_file_project_generation_follows_toolchain(
tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool
) -> None:
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
CORE.toolchain = toolchain
with (
patch("esphome.writer.write_cpp") as mock_write_cpp,
patch("esphome.build_gen.platformio.write_project") as mock_pio_project,
patch.object(
type(CORE), "cpp_main_section", new_callable=PropertyMock
) as mock_section,
):
mock_section.return_value = ""
assert main.write_cpp_file() == 0
mock_write_cpp.assert_called_once()
assert mock_pio_project.called is pio_project_written
def test_command_idedata_arduino_prints_json(
tmp_path: Path, capsys: CaptureFixture
) -> None:
"""Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
CORE.toolchain = Toolchain.ARDUINO
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch(
"esphome.arduino8266.toolchain.get_idedata", return_value=data
) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
"""A missing native build (no idedata) returns an error, not a crash."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
CORE.toolchain = Toolchain.ARDUINO
with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1
@pytest.mark.parametrize(
("platform", "toolchain", "module"),
[
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
],
)
def test_command_analyze_memory_native_toolchains(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
mock_ram_strings_analyzer: Mock,
platform: str,
toolchain: Toolchain,
module: str,
) -> None:
"""analyze-memory uses the native toolchain's binutils instead of
falling into the PlatformIO branch."""
setup_core(platform=platform, tmp_path=tmp_path, name="test_device")
CORE.toolchain = toolchain
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
# The tools must exist: a missing binutils now fails by name instead of
# silently falling back to host tools
objdump = tmp_path / "objdump"
readelf = tmp_path / "readelf"
objdump.write_text("")
readelf.write_text("")
# The ELF must exist too: the analyzer swallows tool failures, so a
# missing image would report zeroes with exit 0
firmware_elf = tmp_path / "firmware.elf"
firmware_elf.write_text("")
with (
patch(f"{module}.get_objdump_path", return_value=objdump),
patch(f"{module}.get_readelf_path", return_value=readelf),
patch(f"{module}.get_elf_path", return_value=firmware_elf),
):
result = command_analyze_memory(MockArgs(), config)
assert result == 0
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
str(objdump),
str(readelf),
set(),
idedata=None,
)
def test_command_analyze_memory_native_missing_elf_fails(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
mock_ram_strings_analyzer: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A missing firmware.elf fails by name instead of an exit-0 zeroed
report."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.ARDUINO
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
objdump = tmp_path / "objdump"
readelf = tmp_path / "readelf"
objdump.write_text("")
readelf.write_text("")
module = "esphome.arduino8266.toolchain"
with (
patch(f"{module}.get_objdump_path", return_value=objdump),
patch(f"{module}.get_readelf_path", return_value=readelf),
patch(f"{module}.get_elf_path", return_value=tmp_path / "missing.elf"),
):
result = command_analyze_memory(MockArgs(), config)
assert result == 1
assert "compile the configuration first" in caplog.text
mock_memory_analyzer_cli.assert_not_called()
def test_command_analyze_memory_missing_binutils_fails_by_name(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A truncated toolchain install fails naming the missing tool instead
of silently analyzing with host binutils."""
setup_core(platform="esp8266", tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.ARDUINO
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
module = "esphome.arduino8266.toolchain"
with (
patch(f"{module}.get_objdump_path", return_value=tmp_path / "missing-objdump"),
patch(f"{module}.get_readelf_path", return_value=tmp_path / "readelf"),
patch("esphome.__main__.write_cpp", return_value=0),
patch("esphome.__main__.compile_program", return_value=0),
):
assert command_analyze_memory(MockArgs(), config) == 1
assert "missing-objdump" in caplog.text
assert "toolchain install may be incomplete" in caplog.text
def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None:
"""A non-native, non-platformio toolchain errors out cleanly."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
CORE.toolchain = Toolchain.SDK_NRF
assert command_idedata(MagicMock(), CORE.config) == 1
@pytest.mark.parametrize(
"error",
[
@@ -7490,46 +7294,6 @@ def test_compile_program_espidf_idedata_none_warns(
assert "No idedata was generated" in caplog.text
def test_native_toolchain_table_serves_every_native_toolchain() -> None:
"""Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would
surface as a targeted EsphomeError on the one affected config, and this
pin keeps the table from drifting when a toolchain is added."""
from esphome.const import NATIVE_TOOLCHAINS
assert {tc for _, tc in main._NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS)
def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None:
"""A native toolchain missing from the backend table is a bug and must
fail, not silently degrade to the PlatformIO path."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.ARDUINO
with (
patch.dict(main._NATIVE_TOOLCHAIN_MODULES, clear=True),
pytest.raises(EsphomeError, match="no native build backend"),
):
_native_toolchain_module()
def test_command_analyze_memory_unsupported_toolchain(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A hook-less non-PlatformIO toolchain is refused by name, never routed
into the PlatformIO branch."""
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.SDK_NRF
mock_write_cpp.return_value = 0
mock_compile_program.return_value = 0
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
assert result == 1
assert "analyze-memory is not supported" in caplog.text
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
"""An explicit --toolchain must run the per-platform validators, so the
upload/logs fast path becomes a cache miss."""
@@ -7545,29 +7309,6 @@ def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
mock_read.assert_called_once()
def test_upload_using_esptool_native_missing_firmware_raises(
tmp_path: Path,
) -> None:
"""A stale or absent firmware.bin fails by name instead of flashing air."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(EsphomeError, match="compile the configuration first"):
upload_using_esptool(
{CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", None, None
)
def test_compile_program_unclaimed_native_toolchain_raises(
tmp_path: Path,
) -> None:
"""A resolved native toolchain no platform backend claims must fail,
never fall through to the PlatformIO project path."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend
with pytest.raises(EsphomeError, match="no platform backend claimed"):
compile_program(MockArgs(), {})
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
tmp_path: Path,
) -> None:
@@ -1,460 +0,0 @@
"""Tests for esphome/platformio/pch.py.script against a fake SCons env."""
from __future__ import annotations
import os
from pathlib import Path
import stat
from unittest.mock import patch
import pytest
from esphome.platformio import toolchain
pytestmark = pytest.mark.skipif(
os.name == "nt", reason="the fake compiler is a POSIX shell script"
)
_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script"
class _FakePlatform:
packages = {"framework-x": {}, "toolchain-y": {}}
def get_package_version(self, name: str) -> str:
if name == "toolchain-y":
raise KeyError(name)
return "1.2.3"
def get_package(self, name: str) -> object | None:
return None
class _BrokenPlatform(_FakePlatform):
def get_package_version(self, name: str) -> str:
raise RuntimeError("manifest parse error")
class _UnresolvedPlatform(_FakePlatform):
"""KeyError from a package that IS installed: unresolved identity."""
def get_package_version(self, name: str) -> str:
raise KeyError(name)
def get_package(self, name: str) -> object:
return object()
class _FakeSConsEnv(dict):
"""Just enough of a SCons construction environment for pch.py."""
def __init__(
self,
proj_dir: Path,
src_dir: Path,
cxx: str,
flags: list[str],
platform_cls: type[_FakePlatform] = _FakePlatform,
):
super().__init__(ENV={})
self._subst = {
"$PROJECT_DIR": str(proj_dir),
"$PROJECT_SRC_DIR": str(src_dir),
"$CXX": cxx,
}
self._flags = flags
self._platform_cls = platform_cls
self.prepended: list[str] = []
def subst(self, expr: str) -> str: # noqa: N802
return self._subst[expr]
def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802
return [self._flags]
def PioPlatform(self) -> _FakePlatform: # noqa: N802
return self._platform_cls()
def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803
self.prepended = CXXFLAGS
def _fake_cxx(
tmp_path: Path,
fail: bool = False,
fail_msg: str | None = None,
reject_pch: bool = False,
probe_exit: int = 0,
) -> Path:
"""A compiler stand-in that records its argv and writes the -o target.
With reject_pch it builds the .gch fine but, like GCC 10 on macOS arm64,
warns on any consuming compile that the .gch cannot be loaded; probe_exit
sets the exit code of non-header compiles (the load probe).
"""
cxx = tmp_path / "fake-gxx"
body = (
'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n'
)
if fail:
body += f"echo {fail_msg or 'boom'} >&2\nexit 1\n"
else:
# Only the c++-header compile has a -o; the load probe has none
body += 'out=""; prev=""; mf=0; dep=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
# Real cc1plus rejects -MF without a dependency flag
body += 'if [ "$mf" = 1 ] && [ "$dep" = 0 ]; then echo "cc1plus: error: to generate dependencies you must specify either \x27-M\x27 or \x27-MM\x27" >&2; exit 1; fi\n'
body += '[ -n "$out" ] && echo gch > "$out"\n'
if reject_pch:
body += 'case " $* " in *c++-header*) ;; *) echo "warning: esphome_pch.h.gch: had text segment at different address" >&2;; esac\n'
body += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n'
cxx.write_text("#!/bin/sh\n" + body)
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
return cxx
def _run_script(
tmp_path: Path,
flags: list[str] | None = None,
fail: bool = False,
fail_msg: str | None = None,
reject_pch: bool = False,
probe_exit: int = 0,
missing_cxx: bool = False,
env_vars: dict[str, str] | None = None,
name: str = "dev",
platform_cls: type[_FakePlatform] = _FakePlatform,
) -> _FakeSConsEnv:
proj = tmp_path / name
src = proj / "src"
(src / "esphome" / "core").mkdir(parents=True, exist_ok=True)
(src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n")
cxx = _fake_cxx(
tmp_path,
fail=fail,
fail_msg=fail_msg,
reject_pch=reject_pch,
probe_exit=probe_exit,
)
if missing_cxx:
cxx = tmp_path / "no-such-gxx"
args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls)
# Distinct objects: the -include flags must land on projenv only
global_env = _FakeSConsEnv(*args)
projenv = _FakeSConsEnv(*args)
projenv.global_env = global_env
source = _SCRIPT.read_text()
with patch.dict(os.environ, env_vars or {}, clear=True):
exec( # noqa: S102
compile(source, "pch.py", "exec"),
{"Import": lambda *_names: None, "env": global_env, "projenv": projenv},
)
return projenv
def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None:
scons_env = _run_script(tmp_path)
proj = tmp_path / "dev"
assert (proj / "esphome_pch.h").read_text().endswith('"esphome/core/defines.h"\n')
assert (proj / "esphome_pch.h.gch").is_file()
assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64
# Relative include: an absolute path would poison ccache keys
assert scons_env.prepended == [
"-Winvalid-pch",
"-Wno-error=invalid-pch",
"-include",
"esphome_pch.h",
]
# In production projenv["ENV"] aliases os.environ; only the -include
# flags are genuinely scoped to projenv (src compiles)
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true"
assert scons_env.global_env.prepended == []
def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
"""One SCons element stays one compiler argv; -include pairs are
stripped from the .gch compile."""
spaced = tmp_path / "My Configs"
spaced.mkdir()
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "dev" / "src" / "other.h").write_text("")
flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"]
_run_script(tmp_path, flags=flags)
calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n")
gch_call = next(c for c in calls if "c++-header" in c).splitlines()
assert '-DUSB_PRODUCT="Pico 2W"' in gch_call
assert str(spaced) in gch_call
assert "-include" not in gch_call
# The stripped src-resolvable -include is folded into the prefix header
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
assert pch.splitlines()[0] == '#include "other.h"'
def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None:
"""-includefoo.h folds like the separated form, matching the native path."""
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "dev" / "src" / "other.h").write_text("")
_run_script(tmp_path, flags=["-DX=1", "-includeother.h"])
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
assert pch.splitlines()[0] == '#include "other.h"'
def test_pch_script_leaves_absolute_force_includes_unfolded(
tmp_path: Path,
) -> None:
"""An absolute -include resolves through src_dir / name; it must still
stay consumer-only or the host path enters the .sum."""
outside = tmp_path / "outside.h"
outside.write_text("")
_run_script(tmp_path, flags=["-DX=1", "-include", str(outside)])
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
assert "outside.h" not in pch
def test_pch_script_leaves_non_src_force_includes_unfolded(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A user -include outside src/ must not enter the prefix header:
consumers keep their own copy, so folding an unguarded header would
include it twice."""
_run_script(tmp_path, flags=["-DX=1", "-include", "user_extra.h"])
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
assert "user_extra.h" not in pch
assert pch.splitlines()[-1] == '#include "esphome/core/defines.h"'
assert "not precompiling non-src force-includes" in capsys.readouterr().out
def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None:
"""Regression: identical configs in different dirs share cache keys."""
sums = []
for name in ("dev_a", "dev_b"):
proj = tmp_path / name
_run_script(
tmp_path,
flags=["-DX=1", "-I", str(proj / "include")],
env_vars={"CCACHE_BASEDIR": str(proj)},
name=name,
)
sums.append((proj / "esphome_pch.h.gch.sum").read_text())
(tmp_path / "fake-gxx").unlink()
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
assert sums[0] == sums[1]
def test_pch_script_transient_compiler_failure_does_not_latch(
tmp_path: Path,
) -> None:
"""ENOSPC-style failures clear on their own; no .failed marker."""
scons_env = _run_script(tmp_path, fail=True, fail_msg="No space left on device")
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch.failed").exists()
assert scons_env.prepended == []
def test_pch_script_failure_marker_suppresses_retry(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
scons_env = _run_script(tmp_path, fail=True)
proj = tmp_path / "dev"
assert (proj / "esphome_pch.h.gch.failed").is_file()
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
# Second run: same checksum, no compile attempt, but says so
attempts = (tmp_path / "fake-gxx.argv").read_text().count("c++-header")
_run_script(tmp_path, fail=True)
out = capsys.readouterr().out
assert (tmp_path / "fake-gxx.argv").read_text().count("c++-header") == attempts
assert "delete esphome_pch.h.gch.failed to retry" in out
def test_pch_script_probe_rejection_falls_back(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A toolchain that cannot load its own .gch (GCC 10 on macOS arm64)
must not leave consumers paying for a pch every compile rejects."""
scons_env = _run_script(tmp_path, reject_pch=True)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch").exists()
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert (proj / "esphome_pch.h.gch.failed").is_file()
assert scons_env.prepended == []
assert "toolchain cannot load the pch" in capsys.readouterr().out
def test_pch_script_spawn_failure_is_transient(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A spawn failure must not latch a .failed marker (matches espidf)."""
scons_env = _run_script(tmp_path, missing_cxx=True)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch.failed").exists()
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "did not run" in capsys.readouterr().out
def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None:
"""A probe failure whose stderr never mentions .gch must still count."""
scons_env = _run_script(tmp_path, probe_exit=1)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch").exists()
assert (proj / "esphome_pch.h.gch.failed").is_file()
assert scons_env.prepended == []
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
"""A KeyError for an installed package is unresolved identity, not absence."""
scons_env = _run_script(tmp_path, platform_cls=_UnresolvedPlatform)
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert scons_env.prepended == []
def test_pch_script_package_version_error_skips_pch(tmp_path: Path) -> None:
"""Without trustworthy package identity a stale .gch could survive an
upgrade, so the script must not build one at all."""
scons_env = _run_script(tmp_path, platform_cls=_BrokenPlatform)
proj = tmp_path / "dev"
assert not (proj / "esphome_pch.h.gch").exists()
assert scons_env.prepended == []
def test_pch_script_corrupt_sidecar_reads_as_stale(tmp_path: Path) -> None:
"""A truncated/corrupt .failed marker must not disable the pch forever."""
_run_script(tmp_path, fail=True)
proj = tmp_path / "dev"
(proj / "esphome_pch.h.gch.failed").write_bytes(b"\xff\xfe corrupt")
_run_script(tmp_path)
assert (proj / "esphome_pch.h.gch").is_file()
assert (proj / "esphome_pch.h.gch.sum").is_file()
def test_pch_script_rebuilds_when_header_missing(tmp_path: Path) -> None:
_run_script(tmp_path)
proj = tmp_path / "dev"
(proj / "esphome_pch.h").unlink()
_run_script(tmp_path)
assert (proj / "esphome_pch.h").is_file()
def test_copy_pch_script(tmp_path: Path) -> None:
from esphome.core import CORE
CORE.build_path = tmp_path
toolchain.copy_pch_script()
assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text()
def test_pch_script_unions_user_sloppiness(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A user CCACHE_SLOPPINESS without the pch tokens gets them unioned on,
mirroring ccache_pch_env, or every src TU is a permanent miss."""
scons_env = _run_script(tmp_path, env_vars={"CCACHE_SLOPPINESS": "locale"})
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
assert "adding pch_defines,time_macros" in capsys.readouterr().out
def test_pch_script_unmodelable_flag_skips_pch(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Unbalanced quotes and multi-token elements cannot be reproduced as
one argv; the pch is skipped rather than built with diverging flags."""
for bad in ("-DFOO='bar", "-DA=1\t-DB=2"):
scons_env = _run_script(tmp_path, flags=["-DX=1", bad])
assert scons_env.prepended == []
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert "unmodelable flag" in capsys.readouterr().out
@pytest.mark.skipif(
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
)
def test_pch_script_unlistable_include_dir_skips_pch(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""An unlistable subtree must not silently drop out of the digest."""
proj = tmp_path / "dev"
override = proj / "lwip_override"
hidden = override / "hidden"
hidden.mkdir(parents=True)
(hidden / "gen.h").write_text("")
hidden.chmod(0)
try:
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
finally:
hidden.chmod(0o755)
assert scons_env.prepended == []
assert "skipping precompiled header" in capsys.readouterr().out
def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None:
"""-t nobuild never exports projenv; the script must not abort."""
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
def strict_import(*names: str) -> None:
if "projenv" in names:
raise RuntimeError("Import of non-existent variable 'projenv'")
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
exec( # noqa: S102
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
assert not (proj / "esphome_pch.h").exists()
def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None:
""".piolibdeps and non-header files must not enter the digest (or be
read at all); package versions already cover library identity."""
proj = tmp_path / "dev"
libdeps = proj / ".piolibdeps" / "lib" / "src"
libdeps.mkdir(parents=True)
(libdeps / "lib.h").write_text("#define A 1\n")
override = proj / "lwip_override"
override.mkdir(parents=True)
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
(override / "notes.txt").write_text("v1\n")
flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
(libdeps / "lib.h").write_text("#define A 2\n")
(override / "notes.txt").write_text("v2\n")
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() == first
def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
"""Generated headers in project-local -I dirs (e.g. rp2's lwip_override)
must invalidate the checksum when they change."""
proj = tmp_path / "dev"
override = proj / "lwip_override"
override.mkdir(parents=True)
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
flags = ["-DX=1", "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
(override / "lwipopts.h").write_text("#define TCP_MSS 536\n")
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
@pytest.mark.skipif(
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
)
def test_pch_script_unreadable_local_header_skips_pch(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""An unreadable generated header means unknown identity: no pch."""
proj = tmp_path / "dev"
override = proj / "lwip_override"
override.mkdir(parents=True)
secret = override / "lwipopts.h"
secret.write_text("#define TCP_MSS 1460\n")
secret.chmod(0)
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "skipping precompiled header" in capsys.readouterr().out
+1 -31
View File
@@ -516,13 +516,10 @@ def test_clean_build(
dependencies_lock = tmp_path / "dependencies.lock"
dependencies_lock.write_text("lock file")
# idedata caches live under the data dir, not the build path; the
# .arduino.json variant is the native esp8266 toolchain's.
# idedata cache lives under the data dir, not the build path.
idedata_cache = tmp_path / "idedata" / "test.json"
idedata_cache.parent.mkdir()
idedata_cache.write_text("{}")
arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json"
arduino_idedata_cache.write_text("{}")
# Native ESP-IDF toolchain artifacts.
idf_build_dir = tmp_path / "build"
@@ -583,7 +580,6 @@ def test_clean_build(
assert not piolibdeps_dir.exists()
assert not dependencies_lock.exists()
assert not idedata_cache.exists()
assert not arduino_idedata_cache.exists()
assert not idf_build_dir.exists()
assert not managed_components_dir.exists()
assert not pio_components_dir.exists()
@@ -677,32 +673,6 @@ def test_clean_build_partial_exists(
assert "dependencies.lock" not in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_partial_removes_pch_artifacts(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""The PlatformIO pch sidecars live at the project root and must go in
a partial clean, like the native backend's under .pioenvs."""
names = (
"esphome_pch.h",
"esphome_pch.h.gch",
"esphome_pch.h.gch.sum",
"esphome_pch.h.gch.failed",
)
for name in names:
(tmp_path / name).write_text("x")
mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs"
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
clean_build()
for name in names:
assert not (tmp_path / name).exists()
@patch("esphome.writer.CORE")
def test_clean_build_nothing_exists(
mock_core: MagicMock,