Compare commits

...
Author SHA1 Message Date
J. Nick Koston 70cec57024 Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-09-03 23:48:54 +02:00
J. Nick Koston 97c712409c Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-09-03 23:48:54 +02:00
J. Nick Koston e4570b783d Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-03 23:48:54 +02:00
J. Nick Koston d6446fdb5f Merge branch 'host-pch' into esp32-pio-pch 2026-09-03 23:48:53 +02:00
J. Nick Koston bc1c91a5f7 Merge branch 'pch-strict-ci' into host-pch 2026-09-03 23:48:53 +02:00
J. Nick Koston e2229f1a5e Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-09-03 23:48:53 +02:00
J. Nick Koston 39dc9b18d3 Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-09-02 11:18:10 +02:00
J. Nick Koston 7d74ec1497 Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-09-02 11:18:10 +02:00
J. Nick Koston b027eb5cce Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-02 11:18:10 +02:00
J. Nick Koston a5701c43ba Merge branch 'host-pch' into esp32-pio-pch 2026-09-02 11:18:09 +02:00
J. Nick Koston b81d250737 Merge branch 'pch-strict-ci' into host-pch 2026-09-02 11:18:09 +02:00
J. Nick Koston 85b03d9dc1 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-09-02 11:18:09 +02:00
J. Nick Koston 8ff2eebe22 Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-30 14:57:41 -05:00
J. Nick Koston 8a20688bf8 Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-30 14:57:41 -05:00
J. Nick Koston f1f28c6969 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston 6d1d439d7b Merge branch 'host-pch' into esp32-pio-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston cdd89dbfed Merge branch 'pch-strict-ci' into host-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston 59367c9c44 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-30 14:57:40 -05:00
J. Nick Koston 453b54768a Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-28 20:52:40 -05:00
J. Nick Koston c546606348 Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-28 20:52:40 -05:00
J. Nick Koston 4be9081349 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:52:40 -05:00
J. Nick Koston de7f9e3d93 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:52:40 -05:00
J. Nick Koston 150f0a3b6c Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:52:39 -05:00
J. Nick Koston cf8872344b Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:52:39 -05:00
J. Nick Koston a11b390cfd Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-28 20:45:05 -05:00
J. Nick Koston 3afe5cd082 Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-28 20:45:05 -05:00
J. Nick Koston e3946c1871 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston 6a6a6679bc Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston 4af285a5a0 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston e43d2f71f4 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:45:04 -05:00
J. Nick Koston 03a8c1ebdb Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-28 20:41:33 -05:00
J. Nick Koston 9509c02836 Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-28 20:41:33 -05:00
J. Nick Koston 9c8e560b7f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:41:33 -05:00
J. Nick Koston 0b0f86527d Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 20:41:33 -05:00
J. Nick Koston 168d96d9f4 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 20:41:32 -05:00
J. Nick Koston e6dfdc8009 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 20:41:32 -05:00
J. Nick Koston 2fa702b7b2 Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-28 13:52:02 -05:00
J. Nick Koston 646822c12b Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-28 13:52:02 -05:00
J. Nick Koston a2f791e846 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:52:02 -05:00
J. Nick Koston 9b1223b700 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 13:52:01 -05:00
J. Nick Koston 94fc9ec63a Merge branch 'pch-strict-ci' into host-pch 2026-08-28 13:52:01 -05:00
J. Nick Koston 340ac06699 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 13:52:01 -05:00
J. Nick Koston e968e3d1eb Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-28 13:07:07 -05:00
J. Nick Koston c3f4a58ecd Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-28 13:07:07 -05:00
J. Nick Koston 7ae325500e Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:07:07 -05:00
J. Nick Koston 5c970df827 Merge branch 'host-pch' into esp32-pio-pch 2026-08-28 13:07:07 -05:00
J. Nick Koston e51f711883 Merge branch 'pch-strict-ci' into host-pch 2026-08-28 13:07:06 -05:00
J. Nick Koston ece74ee0d1 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-28 13:07:06 -05:00
J. Nick Koston 86fdfe7d78 Merge branch 'esp8266-native-parallel-extract' into espidf-parallel-tool-extract 2026-08-27 23:03:25 -05:00
J. Nick Koston 5ef7e45b6a Merge remote-tracking branch 'origin/esp8266-native-parallel-extract' into esp8266-native-parallel-extract 2026-08-27 23:02:24 -05:00
J. Nick Koston 33885bfddf Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston 853ef14f10 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston addcb472af Merge branch 'pch-strict-ci' into host-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston 4ecbde8bbc Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 23:01:19 -05:00
J. Nick Koston a196d61e0b Merge branch 'nrf52-pch' into esp8266-native-parallel-extract 2026-08-27 23:01:19 -05:00
J. Nick Koston d09e0e3f6f Skip unverifiable archives and count distinct tools 2026-08-27 21:24:40 -05:00
J. Nick Koston f7b30d6b85 Count prefetched archives when skipping the parallel pass 2026-08-27 21:07:02 -05:00
J. Nick Koston 7ecb9d3b73 Print per-tool completion during parallel extraction 2026-08-27 20:09:58 -05:00
J. Nick Koston 1fe3475f18 Polish pre-extraction failure reporting 2026-08-27 20:02:20 -05:00
J. Nick Koston 5da2cca3e7 Clarify pre-extraction failure messages 2026-08-27 19:30:29 -05:00
J. Nick Koston 083c35bccd Cancel queued extractions on interrupt and name cached archives 2026-08-27 19:21:20 -05:00
J. Nick Koston ce15f9a331 Verify pre-existing tool archives and escalate surviving torn dirs 2026-08-27 19:21:20 -05:00
J. Nick Koston 9ea429f0ef Clean torn tool dirs and escalate total pre-extraction failure 2026-08-27 19:21:20 -05:00
J. Nick Koston aaafc1753a Share tool resolution between the idf_tools helper scripts 2026-08-27 19:21:20 -05:00
J. Nick Koston c1c9408035 Extract prefetched ESP-IDF tool archives in parallel 2026-08-27 19:21:19 -05:00
J. Nick Koston b2e626d484 Run the sequential remainder after the batch 2026-08-27 19:21:15 -05:00
J. Nick Koston 2b64db9942 Announce size-less refetches and name every install failure 2026-08-27 18:46:09 -05:00
J. Nick Koston 2c6ff030d9 Simplify batch failure logging and dedupe test scaffolding 2026-08-27 18:23:49 -05:00
J. Nick Koston 85a33d5d7f Announce batched refetches and cap preinstall workers 2026-08-27 18:17:29 -05:00
J. Nick Koston 31aae57b2d Trim docstrings and comments 2026-08-27 18:03:20 -05:00
J. Nick Koston a53ffa781a Cap extraction workers and keep batch failures traceable 2026-08-27 17:59:07 -05:00
J. Nick Koston 563083b4a9 Extract native toolchain package archives in parallel 2026-08-27 17:30:29 -05:00
J. Nick Koston 1f77384389 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston b97ba73757 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston b5c78a4711 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston a114147e03 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 15:53:00 -05:00
J. Nick Koston 02761d72ab Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 14:04:03 -05:00
J. Nick Koston 5f9af0cfa5 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 14:04:03 -05:00
J. Nick Koston 5bc3102c82 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 14:04:02 -05:00
J. Nick Koston a5e4882196 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 14:04:02 -05:00
J. Nick Koston 52879b8a7f Skip the doomed gch compile after header-generation failure, self-heal the placeholder from CMake 2026-08-27 13:43:36 -05:00
J. Nick Koston 330adb9ba4 Cover the placeholder-touch failure path 2026-08-27 12:53:06 -05:00
J. Nick Koston e3c1d51f4f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:46:19 -05:00
J. Nick Koston 640f2de767 Merge user platformio option strings into list options, scope the esp32 PlatformIO trigger to the pch modules 2026-08-27 12:45:59 -05:00
J. Nick Koston f180c95a65 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:42:50 -05:00
J. Nick Koston aa25b5fb0b Annotate the compile_ctx fixture 2026-08-27 12:42:36 -05:00
J. Nick Koston c8b54a33a9 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:39:40 -05:00
J. Nick Koston 17d1874ca3 Degrade on header-generation failure, surface probe and touch errors 2026-08-27 12:38:42 -05:00
J. Nick Koston c4c6fe198e Merge branch 'pch-strict-ci' into host-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston 7850fba837 Merge branch 'platformio-pch-libretiny' into pch-strict-ci 2026-08-27 12:38:39 -05:00
J. Nick Koston f66eb89f28 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston 168c772e79 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston a638ac3982 Trim pch comments to repo standard, use partial for the prepare thunk 2026-08-27 11:05:10 -05:00
J. Nick Koston d45e0074a3 Share the guarded pch prepare contract, simplify wiring and tests 2026-08-27 11:01:46 -05:00
J. Nick Koston cf084bc70f Share the CMake pch consumer block, apply review suggestions 2026-08-27 10:53:26 -05:00
J. Nick Koston 21c1c582b5 Generate the Zephyr syscall headers before compiling the pch 2026-08-27 10:44:38 -05:00
J. Nick Koston 8defc48611 Resolve the sysbuild app domain dir for the pch 2026-08-27 10:34:17 -05:00
J. Nick Koston 10ea99d4c9 Add precompiled header support to the nRF52 sdk-nrf build 2026-08-27 10:25:45 -05:00
J. Nick Koston a4136c8a17 Run copy_files to completion in the gate test, pin the build_helpers trigger 2026-08-27 10:11:55 -05:00
J. Nick Koston de0f706da4 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 09:41:01 -05:00
J. Nick Koston 41d68d2978 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 09:40:58 -05:00
J. Nick Koston 59556a730d Keep the parity test hermetic, give touch the shared error contract 2026-08-27 09:40:54 -05:00
J. Nick Koston b367238273 Adopt the shared prefix constant everywhere, trigger CI on build_helpers, pin the copy_files gate 2026-08-27 09:31:31 -05:00
J. Nick Koston 5ab690cca6 Merge branch 'host-pch' into esp32-pio-pch 2026-08-27 09:13:57 -05:00
J. Nick Koston 7d4e28b5e2 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 09:13:55 -05:00
J. Nick Koston 87cbe42cae Blame the baseline honestly, split sidecar unlinks, pin the script's knob and import branches 2026-08-27 09:13:51 -05:00
J. Nick Koston b4d6633b19 Enable the pch on esp32 via PlatformIO for CI speed 2026-08-27 08:47:05 -05:00
J. Nick Koston 8180134d30 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 08:40:39 -05:00
J. Nick Koston 8a5d53899c Simplify: strict knob via parse_enable_env, one probe tail, latch folded into _fail, discard owns its policy 2026-08-27 08:40:35 -05:00
J. Nick Koston 92b71f8ce4 Exclude the prefix wrapper from esphome.h, state the failure-path cost honestly 2026-08-27 08:33:25 -05:00
J. Nick Koston 4605ac08a5 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 08:32:13 -05:00
J. Nick Koston 3e5688d6cd Blame the pch only when a baseline passes, validate the strict knob, abort on an undiscardable stale header 2026-08-27 08:32:01 -05:00
J. Nick Koston 9580fd14da Merge branch 'pch-strict-ci' into host-pch 2026-08-27 00:40:42 -05:00
J. Nick Koston d38542e48b Escalate all strict consumers, keep cached-probe rejections unlatched, drop the dead probe knob 2026-08-27 00:40:37 -05:00
J. Nick Koston 364c530832 Merge branch 'pch-strict-ci' into host-pch 2026-08-27 00:26:00 -05:00
J. Nick Koston c89c2d975d Strict escalates consumer invalid-pch, re-probes cached headers everywhere, and defaults the matrix to strict 2026-08-27 00:25:38 -05:00
J. Nick Koston a0c3a010e4 Guard the prefix for C edges, gate it on the pch knob, pin its resolution 2026-08-27 00:21:14 -05:00
J. Nick Koston fd871b8898 Precompile a curated core-header prefix on host 2026-08-27 00:10:07 -05:00
J. Nick Koston 78eff4f485 Strict re-probes a cached .gch, probe diagnostics get the exit-code fallback, nobuild detection narrows to ImportError 2026-08-26 23:43:09 -05:00
J. Nick Koston 833af674bf Simplify: shared tool-step runner, probe args helper, buildtool touch stamp, knob-spelling parity 2026-08-26 23:20:06 -05:00
J. Nick Koston 15d4f187c7 Bake the probe stamp into the rule text; edge bindings cannot expand $out 2026-08-26 23:10:55 -05:00
J. Nick Koston 21643396c4 Add an esp8266 native-toolchain smoke entry to the strict compile matrix 2026-08-26 23:03:00 -05:00
J. Nick Koston 34daa6138a Probe failures follow the compile branch's transient rules, strict on the native esp8266 job 2026-08-26 23:02:18 -05:00
J. Nick Koston 0266b7f0fb Cover the probe spawn failure and both probe-edge flag branches 2026-08-26 22:55:52 -05:00
J. Nick Koston 5d690104b5 Strict fails on a disabled pch, narrows the projenv exemption, and covers the new funnel sites 2026-08-26 22:51:53 -05:00
J. Nick Koston 4a3b095172 Probe the built .gch on the native paths so strict CI reds on an unloadable pch 2026-08-26 22:47:42 -05:00
J. Nick Koston 54bff55642 Reraise in strict through the IDF flow, funnel the remaining degrade paths, exempt nobuild 2026-08-26 22:42:03 -05:00
J. Nick Koston 264ccc0989 Add ESPHOME_PCH_STRICT and enforce it in the esp8266/esp32/rp2 image compile tests 2026-08-26 22:29:53 -05:00
43 changed files with 2854 additions and 292 deletions
+13
View File
@@ -21,6 +21,7 @@ on:
- "esphome/core/**"
- "esphome/writer.py"
- "esphome/build_gen/**"
- "esphome/build_helpers/**"
- "esphome/espidf/**"
- "esphome/platformio/**"
- "esphome/components/bk72xx/**"
@@ -197,6 +198,7 @@ jobs:
# the default.
id:
- esp8266-arduino
- esp8266-arduino-native
- esp32-arduino-platformio
- esp32-arduino-esp-idf
- esp32-idf-platformio
@@ -207,6 +209,16 @@ jobs:
- ln882x-arduino
- nrf52
- host
# Strict by default so a new matrix id cannot silently join in the
# degrade-quietly mode the knob exists to catch.
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
include:
- id: bk72xx-arduino
pch_strict: "0"
- id: rtl87xx-arduino
pch_strict: "0"
- id: ln882x-arduino
pch_strict: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
@@ -218,6 +230,7 @@ jobs:
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
-v "${{ github.workspace }}/docker/test_configs:/config" \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+4 -2
View File
@@ -1298,8 +1298,10 @@ jobs:
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
# config first, so a separate config pass is redundant. Strict pch:
# exercises the native ninja pch (and its probe edge) against real
# component configs; the docker matrix smoke-tests both toolchains.
ESPHOME_PCH_STRICT=1 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
@@ -0,0 +1,8 @@
esphome:
name: docker-test-esp8266-native
esp8266:
board: d1_mini
toolchain: arduino
logger:
+3 -4
View File
@@ -23,7 +23,7 @@ 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
from esphome.platformio.registry import install_package, prefetch_packages
from esphome.platformio.registry import install_packages, prefetch_packages
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
@@ -116,10 +116,9 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
("bin", "xtensa-lx106-elf"),
),
)
# Fetch both archives at once; the installs below verify and extract
# Fetch both archives at once; the install verifies and extracts them
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
for name, version, dest, mirrors, expect in specs:
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
install_packages(specs, downloads_dir)
return InstalledPaths(
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
)
+34 -3
View File
@@ -39,8 +39,13 @@ from esphome.build_helpers.pch import (
PCH_HEADER_NAME,
mark_pch_emitted,
pch_checksum,
pch_consumer_escalation,
pch_degraded,
pch_disabled_degraded,
pch_enabled,
pch_header_text,
pch_probe_args,
pch_strict,
)
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import (
@@ -1231,6 +1236,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
"A -include in build_flags prevents the precompiled header from "
"loading; compiling without it"
)
pch_degraded("a user -include precedes the pch")
elif pch_enabled():
# C++ src edges swap the force-includes for one precompiled prefix
# header (same content plus defines.h); C/assembly keep srcflags
@@ -1264,6 +1270,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
pch_degraded(f"identity unknown: {err}")
else:
_LOGGER.info(
"Compiling with a precompiled header "
@@ -1283,13 +1290,37 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
if src_other:
lines.append(f" flags = {' '.join(src_other)}")
# Relative -include: absolute would break cross-device ccache.
# -Wno-error keeps a rejected .gch a warning under user -Werror
# -Wno-error keeps a rejected .gch a warning under user -Werror;
# strict inverts it so any consumer rejection reds the build
# (rejection is per-process, so the probe alone cannot prove
# the consumers)
escalation = pch_consumer_escalation()
cxx_parts = src_other + [
f"-Winvalid-pch -Wno-error=invalid-pch -include {PCH_HEADER_NAME}"
f"-Winvalid-pch {escalation} -include {PCH_HEADER_NAME}"
]
lines.append(f"srccxxflags = {' '.join(cxx_parts)}")
src_cxx_override = ("$srccxxflags", gch)
pch_dep = gch
if pch_strict():
# Consumers wait on the probe stamp, so an unloadable .gch
# reds the build here instead of warning ~100 times
probe = " ".join(pch_probe_args(PCH_HEADER_NAME, source=os.devnull))
lines.append("rule pchprobe")
# $out only expands in rule text, hence the inline stamp
lines.append(
f" command = $cxx $cxxflags $flags {probe}"
" && $python $buildtool touch $out"
)
lines.append(" description = PCHPROBE $out")
# Runs when the .gch is (re)built; strict consumer -Werror
# covers a cached .gch this process cannot load
lines.append(f"build esphome_pch.probe: pchprobe {gch}")
if src_other:
lines.append(f" flags = {' '.join(src_other)}")
pch_dep = f"{gch} esphome_pch.probe"
src_cxx_override = ("$srccxxflags", pch_dep)
mark_pch_emitted()
else:
pch_disabled_degraded()
src_objs = _ninja_compile_edges(
lines,
_collect_sources(src_dir),
+11 -1
View File
@@ -6,6 +6,7 @@ started esphome and must not depend on the package being importable.
Subcommands:
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
copy <src> <dst> copy a file
touch <path> create/update a stamp file
The ar rspfile carries one object path per line (the generating rule must
use ``$in_newline``, never ``$in``).
@@ -83,9 +84,18 @@ def _run_copy(src: str, dst: str) -> int:
return 0
def _run_touch(path: str) -> int:
try:
Path(path).touch()
except OSError as err:
print(f"touch: {path} failed: {err}", file=sys.stderr)
return 1
return 0
# mode -> (handler, expected operand count); surplus argv means a
# mis-specified ninja rule and must error, not silently drop operands
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
def main() -> int:
+6 -24
View File
@@ -291,30 +291,10 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
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"))
"""Consumer block for the component CMakeLists. Baked at generation:
a strict-knob flip takes effect on the next esphome compile; a
hand-run idf.py keeps the old one."""
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
def prepare_pch() -> None:
@@ -323,6 +303,7 @@ def prepare_pch() -> None:
if not pch_enabled():
# Self-cleaning escape hatch: drop any previously built .gch
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_disabled_degraded()
return
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
try:
@@ -334,6 +315,7 @@ def prepare_pch() -> None:
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
)
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_degraded(f"sdkconfig unreadable: {err}")
return
pch.prepare_pch(
CORE.relative_build_path("build"),
+7 -2
View File
@@ -21,12 +21,13 @@ def _ccache_runs(ccache: str) -> bool:
)
def parse_enable_env(name: str) -> bool | None:
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
"""Strictly parse an on/off environment knob; None when unset or invalid.
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
1/true/yes/on and 0/false/no/off count; anything else warns and reads
as unset so the caller's default policy applies.
as unset so the caller's default policy applies — or raises when
``strict`` (a typo must not silently disable a CI gate).
"""
raw = os.environ.get(name)
if raw is None:
@@ -39,6 +40,10 @@ def parse_enable_env(name: str) -> bool | None:
return True
if lowered in FALSY_ENV_STRINGS:
return False
if strict:
from esphome.core import EsphomeError
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
return None
+230 -50
View File
@@ -7,7 +7,8 @@ it too; Arduino.h visibility there is intended (esphome#8693).
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
import hashlib
import json
@@ -64,6 +65,10 @@ PCH_ARTIFACT_NAMES = (
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
# Guarded curated-prefix wrapper for PlatformIO backends without framework
# force-includes (host, esp32); folded by the pch script via build_src_flags.
PCH_PREFIX_HEADER = "esphome/core/pch_prefix.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
@@ -102,6 +107,81 @@ def pch_enabled() -> bool:
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_strict() -> bool:
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
A set-but-unrecognized value raises: a typo must not silently turn
the gate into a no-op that proves nothing.
"""
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
def pch_degraded(reason: str) -> None:
"""Every degrade path funnels through here; strict mode raises."""
if pch_strict():
from esphome.core import EsphomeError
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
def pch_disabled_degraded() -> None:
"""Strict CI must not read "no pch at all" as success."""
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
def pch_probe_tail(source: str = "-") -> list[str]:
"""The syntax-only compile shared by the probe and its baseline."""
return ["-fsyntax-only", "-x", "c++", source]
def pch_probe_args(header: str, source: str = "-") -> list[str]:
"""Flags that load-check a built .gch via a syntax-only compile.
Rejection must be a nonzero exit (never just a wording match), so the
invalid-pch class is always escalated. ``source`` defaults to stdin
(host independent); the ninja probe edge passes a real file.
"""
return [
"-Winvalid-pch",
"-Werror=invalid-pch",
"-include",
header,
*pch_probe_tail(source),
]
def pch_consumer_escalation() -> str:
"""Consumer-side invalid-pch flag: strict reds the build on rejection
(per-process, so the probe alone cannot prove the consumers)."""
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
def pch_cmake_consumer(target: str, sources_var: str) -> str:
"""Emit the CMake block making ``target``'s C++ sources consume the
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
.gch (pch-baked headers drop out of TU depfiles); the -include stays
relative — an absolute path would poison ccache keys."""
if not pch_enabled():
return ""
escalation = pch_consumer_escalation()
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
# wiped the build dir after the header was written (west --pristine)
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
endif()
target_compile_options({target} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties({sources_var} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
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.
@@ -132,10 +212,37 @@ def ccache_pch_env() -> dict[str, str]:
return env
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
"""Run a backend's pch preparation; an optional speedup must never
abort the build. Strict is read first so its own knob error cannot
mask the real failure; discard_pch raises if a stale .gch survives;
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
try:
prepare()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
strict = pch_strict()
discard_pch(build_dir)
if strict:
raise
header = build_dir / PCH_HEADER_NAME
if not header.exists():
try:
header.touch()
except OSError as err:
# The coming OBJECT_DEPENDS error would hide the real cause
_LOGGER.warning("Could not create the pch placeholder: %s", err)
_LOGGER.warning(
"Precompiled header setup failed; compiling without it", exc_info=True
)
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 []
if not pch_enabled():
pch_disabled_degraded()
return []
return ["post:pch.py"]
def pch_header_text(include_headers: Iterable[str]) -> str:
@@ -303,15 +410,30 @@ def discard_pch(build_dir: Path) -> None:
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.
.gch must not force a full rebuild every build. A .gch that survives
an unlink failure would be consumed silently (wrong output, not a
slow build), so that raises.
"""
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)
errors = []
for sidecar in (gch, Path(f"{gch}.sum")):
try:
sidecar.unlink(missing_ok=True)
except OSError as err:
if sidecar.is_file():
from esphome.core import EsphomeError
raise EsphomeError(
f"Could not discard the stale precompiled header: {err}"
) from err
errors.append(err)
for err in errors:
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
if had_gch and header.is_file():
os.utime(header)
with suppress(OSError):
os.utime(header)
def prepare_pch(
@@ -334,6 +456,7 @@ def prepare_pch(
if cmd_and_dir is None:
# Freshness cannot be validated; a leftover .gch must not be consumed
discard_pch(build_dir)
pch_degraded("no usable compile command")
return
cmd, cmd_dir = cmd_and_dir
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
@@ -360,61 +483,118 @@ def prepare_pch(
"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()
pch_degraded(f"identity unknown: {err}")
return
failed_marker = Path(f"{gch}.failed")
def _run(
run_cmd: list[str], what: str, stdin: str | None = None
) -> subprocess.CompletedProcess | None:
"""Spawn one pch tool step; environmental failures discard and
degrade (None): spawn/IO/timeout errors and signal kills never
latch the marker."""
try:
proc = subprocess.run(
run_cmd,
cwd=cmd_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
input=stdin,
capture_output=True,
text=True,
check=False,
timeout=300,
)
except (OSError, subprocess.SubprocessError) as err:
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
discard_pch(build_dir)
pch_degraded(f"{what} did not run: {err}")
return None
if proc.returncode < 0:
# Killed by a signal (OOM, ^C): environmental, do not latch
_LOGGER.warning(
"Precompiled header %s was killed (signal %d); retrying next build",
what,
-proc.returncode,
)
discard_pch(build_dir)
pch_degraded(f"{what} killed by signal {-proc.returncode}")
return None
return proc
def _fail(error: str, reason: str, latch: bool) -> None:
"""Discard and degrade; deterministic failures latch when asked."""
_LOGGER.warning(
"Precompiled header failed; compiling without it: %s", error[:400]
)
# Latching paths keep the full compiler output recoverable
_LOGGER.debug("Full pch output: %s", error)
discard_pch(build_dir)
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
# Skip retries until a header/flag/backend-identity/command change
failed_marker.write_text(checksum + "\n", encoding="utf-8")
os.utime(header)
pch_degraded(f"{reason}: {error[:200]}")
def _probe(latch: bool = True) -> None:
"""Load-check the built .gch: some toolchains build one they then
refuse to load (per-process ASLR). Dep flags are already stripped
from cmd, so no -MF is needed; cmd ends with the fixed
"-x c++-header -c -o" tail. A cached-header rejection may not
reproduce (per-process), so that caller passes latch=False."""
if cmd[-6:-4] != ["-x", "c++-header"]:
# The slice below depends on pch_compile_command's fixed tail
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
discard_pch(build_dir)
pch_degraded("unexpected pch command shape")
return
base = cmd[:-6]
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
if probe is None:
return
if probe.returncode != 0:
# Disambiguate: only blame the pch when the same compile passes
# without it; a failing baseline is its own (latchable) problem
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
if baseline is None:
return
if baseline.returncode == 0:
error = probe.stderr.strip() or f"exit code {probe.returncode}"
_fail(error, "toolchain cannot load the pch", latch=latch)
else:
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
_fail(error, "probe cannot run at all", latch=latch)
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
if pch_strict():
# Rejection is per-process, so a cached .gch must re-prove
# loadability for the strict gate (CI-only cost); no latch,
# since the rejection may not reproduce either
_probe(latch=False)
return
if _read_stamp(failed_marker) == checksum:
_LOGGER.info(
"Precompiled header disabled after an earlier failure; delete %s to retry",
failed_marker,
)
pch_degraded("earlier failure latched")
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)
result = _run(cmd, "compile")
if result is None:
return
error = None
if result.returncode != 0:
error = result.stderr.strip() or f"exit code {result.returncode}"
elif not gch.is_file():
error = "compiler produced no .gch"
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)
_fail(error, "compile failed", latch=True)
return
_probe()
if not gch.is_file():
# The probe discarded a rejected or unrunnable .gch
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
+9
View File
@@ -10,6 +10,7 @@ import subprocess
from typing import Any
from esphome import yaml_util
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
import esphome.codegen as cg
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
from esphome.config_helpers import filter_source_files_from_defines
@@ -57,6 +58,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.platformio.toolchain import copy_pch_script
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
@@ -2455,6 +2457,11 @@ async def to_code(config):
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
# CI-speed only: this toolchain is being dropped, so the pch gets
# the same curated prefix as host with no further investment
cg.add_platformio_option("extra_scripts", pch_extra_scripts())
if pch_enabled():
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
@@ -3372,6 +3379,8 @@ def _write_idf_component_yml():
def copy_files():
_write_sdkconfig()
_write_idf_component_yml()
if not CORE.using_toolchain_esp_idf:
copy_pch_script()
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
+12 -2
View File
@@ -1,3 +1,4 @@
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -10,7 +11,7 @@ from esphome.const import (
ThreadModel,
)
from esphome.core import CORE
from esphome.platformio.toolchain import copy_ccache_script
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.types import ConfigType
from .const import KEY_HOST
@@ -55,9 +56,18 @@ async def to_code(config: ConfigType) -> None:
cg.add_platformio_option("platform", "platformio/native")
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
if pch_enabled():
# Curated prefix for the pch (the script folds it plus defines.h):
# host has no framework force-includes, and the per-TU cost is the
# STL closure behind the core headers. Measured -43% compile CPU.
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
# .gch fails to build or load, the force-include stays and every TU
# parses the closure as text: correct, but slower than no pch.
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
# Called by writer.py
def copy_files() -> None:
copy_ccache_script()
copy_pch_script()
+126
View File
@@ -1,13 +1,24 @@
from __future__ import annotations
import asyncio
from functools import partial
import logging
from pathlib import Path
import re
import shutil
import stat
import subprocess
from esphome import pins
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_cmake_consumer,
pch_enabled,
pch_header_text,
)
import esphome.codegen as cg
from esphome.components.zephyr import (
add_extra_script,
@@ -805,6 +816,9 @@ def _generate_cmake_lists() -> bool:
")",
]
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
lines += consumer.splitlines()
if link_flags:
lines += [
"",
@@ -819,6 +833,66 @@ def _generate_cmake_lists() -> bool:
)
def _app_build_dir(build_dir: Path) -> Path:
"""The CMake binary dir of the app image: sysbuild nests it in a
domain dir named after the app source dir. Probed on disk (the
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
truthful mid-build, unlike an SDK-version check."""
sysbuild_app = build_dir / "zephyr"
try:
cache = (sysbuild_app / "CMakeCache.txt").stat()
except (FileNotFoundError, NotADirectoryError):
return build_dir
# Other stat errors propagate; is_file() would silently mislocate the pch
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
def _prepare_pch(app_dir: Path) -> None:
"""Build the .gch between the cmake and compile phases of west."""
if not pch_enabled():
pch.discard_pch(app_dir)
pch.pch_disabled_degraded()
return
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
app_dir.mkdir(parents=True, exist_ok=True)
write_file_if_changed(
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
)
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
# keep the .sum identity deterministic and skip walking generated/
generated = app_dir / "zephyr" / "include" / "generated"
autoconf = None
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
if candidate.exists():
autoconf = candidate
break
if autoconf is None:
# Fail closed: autoconf.h is the .sum's Kconfig identity
_LOGGER.warning("No autoconf.h found; compiling without the pch")
pch.discard_pch(app_dir)
pch.pch_degraded("autoconf.h missing")
return
try:
autoconf_text = autoconf.read_text(encoding="utf-8")
except OSError as err:
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", autoconf, err
)
pch.discard_pch(app_dir)
pch.pch_degraded(f"autoconf unreadable: {err}")
return
pch.prepare_pch(
app_dir,
PCH_DEFAULT_HEADERS,
(
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
zephyr_data()[KEY_BOARD],
autoconf_text,
*get_project_compile_flags(),
),
)
def _copy_if_exists(src: Path, dst: Path) -> None:
if src.is_file():
shutil.copy2(src, dst)
@@ -871,6 +945,58 @@ def run_compile(args, config: ConfigType) -> bool:
str(source_dir),
]
if pch_enabled():
# Consumers carry the -include; gate the ccache relaxation on it
# (Zephyr auto-enables ccache as the compiler launcher when found)
mark_pch_emitted()
env.update(pch.ccache_pch_env())
# Configure first so the .gch compiles from settled compile DB
# flags. Only when the app DB is missing: input changes wipe the
# build dir, so an existing DB is settled and --cmake-only would
# reconfigure for nothing.
prepare = True
app_dir = _app_build_dir(build_dir)
if not (app_dir / "compile_commands.json").is_file():
if not run_command_ok(
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
raise EsphomeError("nRF52 native build configure failed")
# The configure phase creates the sysbuild domain dir: re-resolve
app_dir = _app_build_dir(build_dir)
# kernel.h needs the build-time syscall headers; under sysbuild
# the target exists only in the app domain's ninja
if not run_command_ok(
[
"cmake",
"--build",
str(app_dir),
"--target",
"zephyr_generated_headers",
],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
# A pch-only prerequisite: degrade, let the real build report.
# Also skip the .gch compile: it would fail on the missing
# headers and latch .gch.failed until an identity change
_LOGGER.warning(
"Zephyr header generation failed; compiling without the pch"
)
pch.discard_pch(app_dir)
pch.pch_degraded("zephyr_generated_headers failed")
prepare = False
else:
prepare = True
app_dir = _app_build_dir(build_dir)
if prepare:
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
if not run_command_ok(
west_cmd,
env=env,
+5 -4
View File
@@ -1143,13 +1143,14 @@ class EsphomeCore:
def add_platformio_option(
self, key: str, value: str | list[str], *, replace: bool = False
) -> None:
"""Set a platformio.ini option; list values append to an existing list
unless ``replace`` is True, which overwrites any existing value."""
"""Set a platformio.ini option; values append to an existing list
(a string as one element) unless ``replace`` is True, which
overwrites any existing value."""
new_val = value
old_val = self.platformio_options.get(key)
if not replace and isinstance(old_val, list):
assert isinstance(value, list)
new_val = old_val + value
# A user platformio_options string must merge, not assert
new_val = old_val + ([value] if isinstance(value, str) else value)
self.platformio_options[key] = new_val
def _get_variable_generator(self, id):
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Curated precompiled-header prefix for backends that force-include it via
// build_src_flags (the pch script folds it into the .gch). Guarded because
// build_src_flags also reaches C and assembly src edges.
#ifdef __cplusplus
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
#endif
+68
View File
@@ -0,0 +1,68 @@
"""Shared tool resolution for the sibling idf_tools-backed scripts.
Importable because ``python <script>`` puts this directory first on
sys.path; ``idf_tools`` itself comes from PYTHONPATH.
"""
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
from collections.abc import Callable, Iterator
import os
from pathlib import Path
from idf_tools import (
CURRENT_PLATFORM,
TOOLS_FILE,
IDFEnv,
ToolBinaryError,
add_and_check_targets,
expand_tools_arg,
g,
load_tools_info,
)
def init_idf_tools(idf_framework_root: str) -> None:
"""Point idf_tools' globals at the framework and IDF_TOOLS_PATH."""
g.idf_path = idf_framework_root
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
g.tools_json = str(Path(g.idf_path) / TOOLS_FILE)
def archive_name(download: object) -> str:
"""The dist/ filename idf_tools downloads and installs this from."""
return download.rename_dist or Path(download.url).name
def iter_tool_downloads(
targets_csv: str,
tool_specs: list[str],
on_broken: Callable[[str, ToolBinaryError], bool],
) -> Iterator[tuple[object, str, str, object]]:
"""Yield (tool, name, version, download) per uninstalled tool, mirroring
``idf_tools.py install``'s expansion; ``on_broken(name, err)`` returns
True to treat a tool with a failing installed binary as not installed."""
targets = add_and_check_targets(IDFEnv.get_idf_env(), targets_csv)
tools_info = load_tools_info()
for name in expand_tools_arg(tool_specs, tools_info, targets):
if "@" in name:
name, version = name.split("@", 1)
else:
version = None
tool = tools_info.get(name)
if tool is None or not tool.compatible_with_platform():
continue
version = version or tool.get_recommended_version()
if version is None:
continue
try:
tool.find_installed_versions()
except ToolBinaryError as e:
if not on_broken(name, e):
continue
if version in tool.versions_installed or version not in tool.versions:
continue
download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM)
if download is None:
continue
yield tool, name, version, download
+62 -10
View File
@@ -20,6 +20,7 @@ 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 (
BATCH_EXTRACT_WORKERS,
PathType,
create_venv,
download_and_extract,
@@ -27,6 +28,7 @@ from esphome.framework_helpers import (
failure_reason,
get_python_env_executable_path,
get_system_python_path,
is_expected_fetch_error,
resume_fetch_job,
rmdir,
run_batch_downloads,
@@ -34,9 +36,9 @@ from esphome.framework_helpers import (
run_command_ok,
str_to_lst_of_str,
tool_version_runs,
warn_prefetch_failures,
warn_batch_failures,
)
from esphome.helpers import write_file_if_changed
from esphome.helpers import get_usable_cpu_count, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -292,11 +294,13 @@ def _run_idf_tools_script(
msg: str,
args: list[str] | None = None,
env: dict[str, str] | None = None,
stream_output: bool = False,
) -> tuple[bool, str | None, str | None]:
"""Run one of the sibling idf_tools-backed helper scripts.
The script is executed with the framework's ``tools`` directory on
PYTHONPATH so it imports the framework's own ``idf_tools`` module.
PYTHONPATH carries this directory (sibling imports like
``_tool_resolution``), the esphome package root (``esphome.helpers``),
and the framework's ``tools`` dir (its own ``idf_tools`` module).
"""
cmd = [
get_system_python_path(),
@@ -304,11 +308,20 @@ def _run_idf_tools_script(
str(idf_framework_root),
*(args or []),
]
# Explicit paths: the scripts dir (sibling imports must survive
# PYTHONSAFEPATH), the esphome package root, and the framework's idf_tools
pythonpath = os.pathsep.join(
(
str(_SCRIPTS_DIR),
str(_SCRIPTS_DIR.parents[1]),
str(Path(idf_framework_root) / "tools"),
)
)
return run_command(
cmd,
msg=msg,
env=(env or os.environ)
| {"PYTHONPATH": str(Path(idf_framework_root) / "tools")},
env=(env or os.environ) | {"PYTHONPATH": pythonpath},
stream_output=stream_output,
)
@@ -726,9 +739,9 @@ def _prefetch_idf_tool_archives(
dist_path = get_idf_tools_path() / "dist"
entries = []
seen_dests: set[str] = set()
# Pre-existing archives are not skipped: download_with_resume keeps
# them only on a sha256 match, so the pre-extraction can trust dist/
for entry in json.loads(stdout):
if (dist_path / entry["dest"]).is_file():
continue
# Never download unverified: an entry without sha256/size is
# left to the installer, which fails loudly on a bad archive.
# Checked before the dedupe so it cannot shadow a verifiable
@@ -748,9 +761,11 @@ def _prefetch_idf_tool_archives(
entries.append(entry)
if not entries:
return
cached = sum((dist_path / entry["dest"]).is_file() for entry in entries)
_LOGGER.info(
"Downloading %d ESP-IDF tool archive(s): %s",
"Downloading %d ESP-IDF tool archive(s)%s: %s",
len(entries),
f" ({cached} cached, verifying)" if cached else "",
", ".join(entry["name"] for entry in entries),
)
@@ -774,7 +789,7 @@ def _prefetch_idf_tool_archives(
for entry in entries
],
)
warn_prefetch_failures(failures)
warn_batch_failures(failures)
if len(failures) == len(entries):
# A systematic fault, not one flaky mirror: the resume
# workaround (#17703) is off for this whole install
@@ -789,6 +804,42 @@ def _prefetch_idf_tool_archives(
_LOGGER.debug("Prefetch failure detail", exc_info=True)
def _preinstall_idf_tool_archives(
framework_path: Path,
targets_str: str,
tools: list[str],
env: dict[str, str] | None,
) -> None:
"""Run install_tool_archives.py to extract the prefetched tool archives
in parallel. Strictly best-effort: the sequential installer remains the
authority (see that script's docstring)."""
try:
success, _stdout, _stderr = _run_idf_tools_script(
framework_path,
"install_tool_archives.py",
"ESP-IDF tool archive extraction",
args=[
targets_str,
str(min(get_usable_cpu_count(), BATCH_EXTRACT_WORKERS)),
*tools,
],
env=env,
stream_output=True,
)
if not success:
# Detail already streamed to the terminal by the script; a
# surviving torn dir prints its own guidance there
_LOGGER.warning("ESP-IDF tool pre-extraction failed; see above")
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# A programming error keeps its traceback at WARNING
_LOGGER.warning(
"ESP-IDF tool pre-extraction failed: %s",
failure_reason(e),
exc_info=None if is_expected_fetch_error(e) else e,
)
_LOGGER.debug("Pre-extraction failure detail", exc_info=True)
def _check_esphome_idf_framework_install(
version: str,
targets: list[str],
@@ -940,6 +991,7 @@ def _check_esphome_idf_framework_install(
_LOGGER.info("Installing ESP-IDF %s framework ...", version)
targets_str = ",".join(targets)
_prefetch_idf_tool_archives(framework_path, targets_str, tools, env)
_preinstall_idf_tool_archives(framework_path, targets_str, tools, env)
cmd = [
get_system_python_path(),
str(idf_tools_path),
+25 -55
View File
@@ -1,8 +1,9 @@
"""Print JSON download info for the ESP-IDF tools an install would fetch.
Run via ``python <this file> <idf_framework_root> <targets-csv> <tool-spec>...``.
PYTHONPATH must include ``<idf_framework_root>/tools`` so ``idf_tools`` is
importable, and IDF_TOOLS_PATH must be set. Prints a JSON list of
PYTHONPATH must include this directory (for ``_tool_resolution``) and
``<idf_framework_root>/tools`` (for ``idf_tools``), and IDF_TOOLS_PATH must
be set. Prints a JSON list of
``{name, url, size, sha256, dest}`` for every tool version that is not yet
installed, where ``dest`` is the archive filename ``idf_tools.py install``
expects to find in ``<IDF_TOOLS_PATH>/dist``. Tools with no download for the
@@ -18,67 +19,36 @@ or written — this script only reports what the install would download.
from contextlib import redirect_stdout
import json
import os
from pathlib import Path
import sys
from idf_tools import (
CURRENT_PLATFORM,
TOOLS_FILE,
IDFEnv,
ToolBinaryError,
add_and_check_targets,
expand_tools_arg,
g,
get_idf_download_url_apply_mirrors,
load_tools_info,
)
from _tool_resolution import archive_name, init_idf_tools, iter_tool_downloads
from idf_tools import ToolBinaryError, get_idf_download_url_apply_mirrors
def collect_downloads() -> list[dict]:
g.idf_path = sys.argv[1]
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
g.tools_json = str(Path(g.idf_path) / TOOLS_FILE)
init_idf_tools(sys.argv[1])
targets = add_and_check_targets(IDFEnv.get_idf_env(), sys.argv[2])
tools_info = load_tools_info()
downloads: list[dict] = []
def on_broken(name: str, e: ToolBinaryError) -> bool:
# A broken installed binary is idf_tools' problem to repair on
# install; note it and treat the version as not installed.
print(f"tool {name} failed its binary check: {e}", file=sys.stderr)
return True
for name in expand_tools_arg(sys.argv[3:], tools_info, targets):
if "@" in name:
name, version = name.split("@", 1)
else:
version = None
tool = tools_info.get(name)
if tool is None or not tool.compatible_with_platform():
continue
version = version or tool.get_recommended_version()
if version is None:
continue
try:
tool.find_installed_versions()
except ToolBinaryError as e:
# A broken installed binary is idf_tools' problem to repair on
# install; note it and treat the version as not installed.
print(f"tool {name} failed its binary check: {e}", file=sys.stderr)
if version in tool.versions_installed or version not in tool.versions:
continue
download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM)
if download is None:
continue
downloads.append(
{
"name": f"{name}@{version}",
# Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS
# rewriting the installer's own downloader applies, so users
# behind a mirror prefetch from the mirror too.
"url": get_idf_download_url_apply_mirrors(None, download.url),
"size": download.size,
"sha256": download.sha256,
"dest": download.rename_dist or Path(download.url).name,
}
return [
{
"name": f"{name}@{version}",
# Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS
# rewriting the installer's own downloader applies, so users
# behind a mirror prefetch from the mirror too.
"url": get_idf_download_url_apply_mirrors(None, download.url),
"size": download.size,
"sha256": download.sha256,
"dest": archive_name(download),
}
for _tool, name, version, download in iter_tool_downloads(
sys.argv[2], sys.argv[3:], on_broken
)
return downloads
]
# idf_tools prints informational lines (e.g. mirror URL rewrites) to stdout;
+129
View File
@@ -0,0 +1,129 @@
"""Extract prefetched ESP-IDF tool archives in parallel.
Run via ``python <this file> <idf_framework_root> <targets-csv> <workers>
<tool-spec>...`` with idf_tools and the esphome package root on PYTHONPATH
and IDF_TOOLS_PATH set.
Drives idf_tools' own ``IDFTool.install()`` so extraction semantics match
the sequential installer, which still runs afterwards as the authority and
redoes anything this best-effort pass failed on. Archives are trusted from
the prefetch's sha256 verification, not re-hashed here.
"""
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import sys
from _tool_resolution import archive_name, init_idf_tools, iter_tool_downloads
from idf_tools import ToolBinaryError, g
from esphome.helpers import rmtree
def collect_pending(
targets_csv: str, tool_specs: list[str]
) -> tuple[dict[tuple[str, str], object], int]:
"""The {(name, version): tool} jobs whose verified archive is on disk,
and how many distinct uninstalled tools were resolved overall."""
dist_path = Path(g.idf_tools_path) / "dist"
def on_broken(name: str, e: ToolBinaryError) -> bool:
# Repairing a broken installed binary is the installer's job
print(f"leaving broken {name} to the installer: {e}", file=sys.stderr)
return False
pending: dict[tuple[str, str], object] = {}
resolved: set[tuple[str, str]] = set()
for tool, name, version, download in iter_tool_downloads(
targets_csv, tool_specs, on_broken
):
resolved.add((name, version))
# Mirror the prefetch: an entry it could not verify is never trusted
if not (download.sha256 and download.size):
continue
# Trusted as-is: the prefetch verifies archives at their final name,
# and the installer redoes anything this pass fails on
if (name, version) in pending or not (
dist_path / archive_name(download)
).is_file():
continue
pending[(name, version)] = tool
return pending, len(resolved)
def install_one(tool: object, name: str, version: str) -> bool | None:
"""True on success, False on a cleaned-up failure, None when the torn
dest dir survived and could fool the installer's binary probe."""
try:
tool.install(version)
# check_binary_valid exits via SystemExit; the installer redoes failures
except (Exception, SystemExit) as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Name the type: idf_tools' fatal() raises SystemExit(1), which
# would render as a bare "1"
print(
f"pre-extracting {name}@{version} failed, leaving it to the "
f"installer: {type(e).__name__}: {e}",
file=sys.stderr,
)
# A torn dest dir must not look installed to the installer
dest = tool.get_path_for_version(version)
try:
rmtree(dest)
except FileNotFoundError: # pragma: no cover # failed before mkdir
pass
except OSError as cleanup_err:
print(
f"could not remove {dest}: {cleanup_err}; the installer may "
"trust the partial tool dir, delete it manually if the build "
"fails",
file=sys.stderr,
)
return None
return False
# Per-tool completion keeps the multi-minute unpack phase visibly alive
print(f"extracted {name}@{version}", flush=True)
return True
def main() -> None:
_script, idf_framework_root, targets_csv, workers_str, *tool_specs = sys.argv
init_idf_tools(idf_framework_root)
pending, resolved = collect_pending(targets_csv, tool_specs)
if len(pending) < 2:
# Nothing to parallelize; the count makes a naming/resolution drift
# that would silently disable this pass observable
print(
f"{len(pending)} of {resolved} uninstalled tool(s) have a "
"prefetched archive; leaving them to the installer",
flush=True,
)
return
workers = min(int(workers_str), len(pending))
print(
f"Extracting {len(pending)} ESP-IDF tool archive(s) with "
f"{workers} worker(s): "
+ ", ".join(f"{name}@{version}" for name, version in pending),
flush=True,
)
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [
ex.submit(install_one, tool, name, version)
for (name, version), tool in pending.items()
]
try:
results = [future.result() for future in futures]
except BaseException: # pragma: no cover
# Ctrl-C: drop queued extractions; in-flight ones finish whole
ex.shutdown(wait=True, cancel_futures=True)
raise
# A survivor could fool the installer; every job failing is systematic.
# Either way a nonzero exit makes the caller warn
failed = sum(result is not True for result in results)
if failed:
print(f"{failed} of {len(results)} pre-extractions failed", file=sys.stderr)
if None in results or failed == len(results):
sys.exit(1)
main()
+4 -13
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,19 +528,11 @@ 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
# After every reconfigure so compile_commands and sdkconfig are settled
from esphome.build_gen.espidf import prepare_pch
from esphome.build_helpers.pch import guarded_prepare
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
)
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
# Build
args = []
+58 -27
View File
@@ -287,10 +287,25 @@ def _detect_archive_root(names: Iterable[str]) -> str | None:
return root if has_descendant else None
def _resolve_progress(
progress: Callable[[float], None] | None,
progress_header: str | None,
has_work: bool,
) -> Callable[[float], None] | None:
"""Fraction reporter for an extractor: the caller's callback wins over a
private ``progress_header`` bar."""
if progress is not None:
return progress
if progress_header and has_work:
return ProgressBar(progress_header).update
return None
def _tar_extract_all(
data: io.BufferedIOBase,
extract_dir: PathType = ".",
progress_header: str | None = None,
progress: Callable[[float], None] | None = None,
):
"""
Extract a TAR archive to the specified directory.
@@ -305,6 +320,7 @@ def _tar_extract_all(
data: File-like object containing the TAR archive
extract_dir: Directory to extract contents to
progress_header: If set, show a progress bar with this header
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
"""
import tarfile
@@ -363,21 +379,20 @@ def _tar_extract_all(
safe_members.append(member)
total = len(safe_members)
progress = (
ProgressBar(progress_header) if progress_header and total > 0 else None
)
report = _resolve_progress(progress, progress_header, total > 0)
for i, member in enumerate(safe_members, 1):
tar_ref.extract(member, abs_dest)
if progress is not None:
progress.update(i / total)
if progress is not None:
progress.update(1)
if report is not None:
report(i / total)
if report is not None:
report(1)
def _zip_extract_all(
data: io.BufferedIOBase,
extract_dir: PathType = ".",
progress_header: str | None = None,
progress: Callable[[float], None] | None = None,
):
"""
Extract a ZIP archive to the specified directory.
@@ -386,6 +401,7 @@ def _zip_extract_all(
data: File-like object containing the ZIP archive
extract_dir: Directory to extract contents to
progress_header: If set, show a progress bar with this header
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
"""
import zipfile
@@ -402,9 +418,7 @@ def _zip_extract_all(
strip_prefix = f"{strip_root}/" if strip_root is not None else None
total = len(all_members)
progress = (
ProgressBar(progress_header) if progress_header and total > 0 else None
)
report = _resolve_progress(progress, progress_header, total > 0)
for i, member in enumerate(all_members, 1):
# 1. Normalize name
@@ -437,10 +451,10 @@ def _zip_extract_all(
# 6. Extract
zip_ref.extract(member, extract_dir)
if progress is not None:
progress.update(i / total)
if progress is not None:
progress.update(1)
if report is not None:
report(i / total)
if report is not None:
report(1)
def _rename_with_retry(
@@ -471,6 +485,7 @@ def _7z_extract_all(
data: io.BufferedIOBase,
extract_dir: PathType = ".",
progress_header: str | None = None,
progress: Callable[[float], None] | None = None,
):
"""
Extract a 7z archive to the specified directory.
@@ -485,6 +500,7 @@ def _7z_extract_all(
data: File-like object containing the 7z archive (must be seekable)
extract_dir: Directory to extract contents to
progress_header: If set, show a progress bar with this header
progress: called with 1.0 on completion; overrides progress_header
"""
import py7zr
@@ -523,19 +539,15 @@ def _7z_extract_all(
continue
safe_targets.append(raw)
progress = (
ProgressBar(progress_header)
if progress_header and safe_targets
else None
)
report = _resolve_progress(progress, progress_header, bool(safe_targets))
if len(safe_targets) == len(all_names):
z.extractall(path=staging)
else:
z.extract(path=staging, targets=safe_targets)
if progress is not None:
progress.update(1)
if report is not None:
report(1)
src_root = staging / strip_root if strip_root else staging
for item in src_root.iterdir():
@@ -566,6 +578,7 @@ def archive_extract_all(
archive: PathType | io.RawIOBase | IO[bytes],
extract_dir: PathType = ".",
progress_header: str | None = None,
progress: Callable[[float], None] | None = None,
):
"""
Extract an archive file to the specified directory.
@@ -574,6 +587,7 @@ def archive_extract_all(
archive: Path to archive file or file-like object
extract_dir: Directory to extract contents to
progress_header: If set, show a progress bar with this header
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
Raises:
TypeError: If archive is not a valid type
@@ -604,7 +618,9 @@ def archive_extract_all(
break
if matched_fct is None:
raise ValueError("Unsupported archive format")
matched_fct(archive_ref, extract_dir, progress_header=progress_header)
matched_fct(
archive_ref, extract_dir, progress_header=progress_header, progress=progress
)
def _open_ranged(
@@ -768,13 +784,17 @@ def _stream_response_to_file(
# hammering the host or the mirrors.
BATCH_DOWNLOAD_WORKERS = 4
# Concurrent archive extractions per batch; unpacking stops scaling well
# before high core counts since the workers share one disk.
BATCH_EXTRACT_WORKERS = 10
def run_batch_downloads(
header: str,
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
max_workers: int = BATCH_DOWNLOAD_WORKERS,
) -> list[tuple[str, BaseException]]:
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
"""Run ``(name, size, fetch)`` jobs concurrently under one bar.
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
the sum of the sizes. Failures are returned after the bar is done so
@@ -949,15 +969,26 @@ def resume_fetch_job(
return fetch
def warn_prefetch_failures(
def is_expected_fetch_error(err: BaseException) -> bool:
"""Download failures the callers degrade on, vs programming errors."""
from esphome.core import EsphomeError # local import avoids circular dependency
return isinstance(err, (EsphomeError, OSError))
def warn_batch_failures(
failures: list[tuple[str, BaseException]],
message: str = "Could not prefetch %s: %s",
) -> None:
"""Warn per failed batch-prefetch job; the caller's installer retries them."""
"""Warn per failed batch job, keeping the traceback of unexpected errors."""
for name, err in failures:
# failure_reason: a message-less exception must not log blank
_LOGGER.warning(message, name, failure_reason(err))
_LOGGER.debug("Prefetch failure detail", exc_info=err)
if is_expected_fetch_error(err):
_LOGGER.warning(message, name, failure_reason(err))
_LOGGER.debug("Failure detail", exc_info=err)
else:
# A programming error must not be reduced to a bare message
_LOGGER.warning(message, name, failure_reason(err), exc_info=err)
def download_with_resume(
+2 -2
View File
@@ -35,7 +35,7 @@ from esphome.framework_helpers import (
failure_reason,
rmdir,
run_batch_downloads,
warn_prefetch_failures,
warn_batch_failures,
)
_LOGGER = logging.getLogger(__name__)
@@ -1091,7 +1091,7 @@ def _prefetch_wave(
+ [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones],
)
# The sequential call below retries and raises the real error
warn_prefetch_failures(
warn_batch_failures(
failures, "Prefetch of %s failed (retrying sequentially): %s"
)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
+86 -34
View File
@@ -20,11 +20,22 @@ except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
# 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.
# values, probe flow, stamp flow, and env-knob spellings 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")
# Keep in sync with helpers.TRUTHY_ENV_STRINGS / FALSY_ENV_STRINGS
_TRUTHY = ("1", "true", "yes", "on", "enable")
_FALSY = ("", "0", "false", "no", "off", "disable")
_STRICT_RAW = os.environ.get("ESPHOME_PCH_STRICT")
_STRICT_VALUE = (_STRICT_RAW or "").strip().lower()
_STRICT = _STRICT_VALUE in _TRUTHY
if _STRICT_RAW is not None and _STRICT_VALUE not in _TRUTHY + _FALSY:
# A typo must not silently turn the gate into a no-op
raise RuntimeError(f"Unrecognized ESPHOME_PCH_STRICT={_STRICT_RAW!r}; use 1 or 0")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
_CORE_HEADER = "esphome/core/defines.h"
@@ -117,26 +128,12 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: 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 []
)
return _probe_gch(cxx, flags, header, proj_dir)
def _probe_run(cxx, flags, extra, proj_dir: Path):
probe = subprocess.run( # noqa: PLW1510
[
cxx,
*flags,
*dep_redirect,
"-Winvalid-pch",
"-include",
str(header),
"-fsyntax-only",
"-x",
"c++",
"-",
],
[cxx, *flags, *extra, "-fsyntax-only", "-x", "c++", "-"],
cwd=proj_dir,
env={**os.environ, "LC_ALL": "C"},
input="",
@@ -145,9 +142,34 @@ def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
)
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
return probe
def _probe_gch(cxx, flags, header: Path, proj_dir: Path):
"""Load-check an existing .gch; error string or None. Rejection must
be a nonzero exit (keep in sync with pch_probe_args); a baseline run
without the pch keeps environmental failures from being blamed on it."""
# -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 = _probe_run(
cxx,
flags,
[*dep_redirect, "-Winvalid-pch", "-Werror=invalid-pch", "-include", str(header)],
proj_dir,
)
if probe.returncode == 0:
return None
baseline = _probe_run(cxx, flags, dep_redirect, proj_dir)
if baseline.returncode != 0:
# Deterministic and latchable; the transient filter at the caller
# keeps resource exhaustion from latching
return f"probe cannot run at all: {baseline.stderr.strip()[:200]}"
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
def _read_stamp(path: Path) -> str:
@@ -158,11 +180,18 @@ def _read_stamp(path: Path) -> str:
return ""
def _setup_pch() -> None:
def _setup_pch() -> bool | 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
try:
from SCons.Script import COMMAND_LINE_TARGETS
except ImportError:
# No SCons is an anomaly under PlatformIO: the unknown state
# must not read as success (strict decides fatality at the gate)
return False
# Expected under -t nobuild (nothing compiles); a missing
# projenv on a real compile must not pass strict
return "nobuild" in [str(t) for t in COMMAND_LINE_TARGETS]
# 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
@@ -286,12 +315,22 @@ def _setup_pch() -> None:
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)
):
fresh = (
header.is_file()
and gch.is_file()
and sum_path.is_file()
and (_read_stamp(sum_path) == checksum)
)
if fresh and _STRICT:
# Rejection is per-process: strict re-proves a cached .gch loads
# (mirrors the pch_strict() re-probe in build_helpers/pch.py)
error = _probe_gch(cxx, flags, header, proj_dir)
if error is not None:
print(f"ESPHome: {error}")
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
return
if not fresh:
failed_marker = Path(f"{gch}.failed")
if _read_stamp(failed_marker) == checksum:
print(
@@ -351,14 +390,27 @@ def _setup_pch() -> None:
# -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]
CXXFLAGS=[
"-Winvalid-pch",
# Strict inverts: a per-process consumer rejection reds the build
"-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch",
"-include",
header.name,
]
)
projenv["ENV"].update(ccache_updates) # noqa: F821
print("ESPHome: Compiling with precompiled header")
return True
try:
_setup_pch()
_used = _setup_pch()
except Exception: # noqa: BLE001 -- a speedup must never break the build
if _STRICT:
raise
print("ESPHome: pch internal error; compiling without it")
traceback.print_exc()
else:
if _STRICT and not _used:
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
+4 -3
View File
@@ -33,12 +33,13 @@ import time
from typing import Any, NamedTuple
from esphome.framework_helpers import (
BATCH_EXTRACT_WORKERS,
content_length,
discard_partial_download,
failure_reason,
resume_fetch_job,
run_batch_downloads,
warn_prefetch_failures,
warn_batch_failures,
)
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
@@ -701,7 +702,7 @@ def _preinstall(
would hang, not fail). Waves skip dependencies; the installed
manifests feed the next wave. Any failure falls back to pio run.
"""
workers = min(get_usable_cpu_count(), len(entries))
workers = min(get_usable_cpu_count(), len(entries), BATCH_EXTRACT_WORKERS)
# One manager per worker (_install mutates instance state); built
# serially because construction rewires the shared manager logger
managers: SimpleQueue = SimpleQueue()
@@ -890,7 +891,7 @@ def _prefetch(build_dir: Path, env: str) -> None:
)
# PlatformIO retries failed packages itself, without resume
failures = run_batch_downloads("Downloading PlatformIO packages", jobs)
warn_prefetch_failures(failures)
warn_batch_failures(failures)
failed_names = {name for name, _ in failures}
elif not groups and not unresolved:
# Record the no-work run so the parent skips the next spawn.
+123 -6
View File
@@ -14,12 +14,16 @@ from typing import NamedTuple
from esphome.core import EsphomeError
from esphome.framework_helpers import (
BATCH_EXTRACT_WORKERS,
archive_extract_all,
download_from_mirrors,
download_with_resume,
is_expected_fetch_error,
rmdir,
run_batch_downloads,
warn_batch_failures,
)
from esphome.helpers import get_usable_cpu_count
from esphome.net_retry import fetch_with_retry, http_request
_LOGGER = logging.getLogger(__name__)
@@ -160,6 +164,14 @@ def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
)
# (name, version, dest, mirrors, expect) as accepted by install_packages
PackageSpec = tuple[str, str, Path, list[str], Collection[str]]
def _archive_path(downloads_dir: Path, name: str, version: str) -> Path:
return downloads_dir / f"{name}-{version}"
class _PendingArchive(NamedTuple):
name: str
version: str
@@ -174,6 +186,26 @@ def _already_installed(dest: Path) -> bool:
return (dest / ".esphome_extracted").is_file()
def _batched_download_progress(
name: str, version: str, size: int | None, extract_progress: Callable[[float], None]
) -> Callable[[int], None]:
"""Download tracker for a batched install: no private bar, no bytes (the
shared bar must never run backwards); the zero tick keeps cancellation
observable and a real refetch is announced once."""
announced = False
def progress(done: int) -> None:
nonlocal announced
# size-less registry entries still announce: streaming starts at
# done=0, while a verify no-op credits the full file in one tick
if not announced and done < (size or 1):
_LOGGER.info("Re-downloading %s %s ...", name, version)
announced = True
extract_progress(0.0)
return progress
def prefetch_packages(
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
) -> None:
@@ -242,7 +274,7 @@ def prefetch_packages(
[(entry.name, entry.size, partial(_fetch, entry)) for entry in pending],
)
for name, err in failures:
if isinstance(err, (EsphomeError, OSError)):
if is_expected_fetch_error(err):
# Expected download failures: install_package retries this one
# itself, with a visible bar
_LOGGER.debug("Prefetch of %s failed: %s", name, err)
@@ -259,6 +291,7 @@ def install_package(
mirrors: list[str],
downloads_dir: Path,
expect: Collection[str],
extract_progress: Callable[[float], None] | None = None,
) -> None:
"""Download, verify, and extract one package if not already installed.
@@ -266,6 +299,9 @@ def install_package(
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
substitution) is trusted as configured. ``downloads_dir`` holds the
archive between runs so an interrupted download resumes.
``extract_progress`` receives extraction fractions in [0, 1] instead of
the private per-file bars (see ``install_packages``).
"""
if not expect:
# Layout validation before marker.touch() is the only guard against
@@ -288,8 +324,15 @@ def install_package(
rmdir(dest, msg=f"Clean up incomplete {name} install")
# Persistent location so an interrupted download resumes across runs.
downloads_dir.mkdir(parents=True, exist_ok=True)
archive = downloads_dir / f"{name}-{version}"
_LOGGER.info("Downloading %s %s ...", name, version)
archive = _archive_path(downloads_dir, name, version)
# Batch header names each package; keep INFO when an archive
# unexpectedly needs a real download (the shared bar won't move)
log = (
_LOGGER.debug
if extract_progress is not None and archive.is_file()
else _LOGGER.info
)
log("Downloading %s %s ...", name, version)
if mirrors:
_LOGGER.warning(
"Downloading %s from a mirror override; checksum verification "
@@ -301,11 +344,85 @@ def install_package(
)
else:
url, sha256, size = registry_download(name, version)
download_with_resume(url, archive, sha256=sha256, size=size)
_LOGGER.info("Extracting %s ...", name)
archive_extract_all(archive, dest, progress_header="Extracting")
download_with_resume(
url,
archive,
sha256=sha256,
size=size,
progress=None
if extract_progress is None
else _batched_download_progress(name, version, size, extract_progress),
)
log("Extracting %s ...", name)
archive_extract_all(
archive, dest, progress_header="Extracting", progress=extract_progress
)
# Validate the layout before recording success, so an unexpected
# package is never cached as a working install.
_check_layout(name, dest, expect)
marker.touch()
archive.unlink(missing_ok=True)
def install_packages(specs: Collection[PackageSpec], downloads_dir: Path) -> None:
"""Install several packages; prefetched archives extract in parallel under
one shared bar, the rest take the sequential ``install_package`` path.
The first failure is re-raised."""
pending: list[tuple[PackageSpec, int]] = []
rest: list[PackageSpec] = []
seen: set[str] = set()
for spec in specs:
name, version, dest, mirrors, _expect = spec
archive = _archive_path(downloads_dir, name, version)
# Duplicate entries share one archive and would race each other
# between two workers; mirror prefetch_packages' dedupe
if _already_installed(dest) or mirrors or archive.name in seen:
rest.append(spec)
continue
try:
# An archive at its final name already passed sha256/size
# verification
size = archive.stat().st_size
except FileNotFoundError:
rest.append(spec)
continue
seen.add(archive.name)
pending.append((spec, size))
if len(pending) < 2:
for name, version, dest, mirrors, expect in specs:
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
return
workers = min(get_usable_cpu_count(), len(pending), BATCH_EXTRACT_WORKERS)
_LOGGER.info(
"Extracting %d package archive(s) with %d worker(s): %s",
len(pending),
workers,
", ".join(spec[0] for spec, _ in pending),
)
def _install(spec: PackageSpec, size: int, tracker: Callable[[int], None]) -> None:
name, version, dest, mirrors, expect = spec
install_package(
name,
version,
dest,
mirrors,
downloads_dir,
expect=expect,
extract_progress=lambda frac: tracker(int(frac * size)),
)
failures = run_batch_downloads(
"Extracting packages",
[(spec[0], size, partial(_install, spec, size)) for spec, size in pending],
max_workers=workers,
)
if failures:
# Warn on the first failure too: the raised exception's message may
# not name which package failed
warn_batch_failures(failures, "Could not install %s: %s")
raise failures[0][1]
# Sequential remainder after the batch, so a duplicate spec cannot
# unlink the archive its batched twin was sized from
for name, version, dest, mirrors, expect in rest:
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
+2 -1
View File
@@ -7,7 +7,7 @@ import re
import time
from esphome import loader
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES, PCH_PREFIX_HEADER
from esphome.compiled_config import save_compiled_config
from esphome.config import iter_component_configs, iter_components
from esphome.const import (
@@ -247,6 +247,7 @@ def copy_src_tree():
Path(
"esphome/core/ring_buffer.h"
), # moved to components/ring_buffer/, removed in 2026.11.0
Path(PCH_PREFIX_HEADER), # build machinery, not user API
}
include_l = []
for target, _ in source_files_l:
+5
View File
@@ -543,6 +543,11 @@ _SMOKE_HARNESS_TRIGGER_FILES = frozenset(
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
"esphome/build_gen/platformio.py",
# The pch machinery the strict smoke job polices, and the modules it
# imports; the rest of build_helpers/ does not affect PlatformIO builds
"esphome/build_helpers/pch.py",
"esphome/build_helpers/ccache.py",
"esphome/build_helpers/idedata.py",
}
+7
View File
@@ -1013,6 +1013,10 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
# PlatformIO subsystem (path-prefix trigger) + build generator
["esphome/platformio/runner.py"],
["esphome/platformio/toolchain.py"],
# The pch modules are standalone triggers, not the whole build_helpers/
["esphome/build_helpers/pch.py"],
["esphome/build_helpers/ccache.py"],
["esphome/build_helpers/idedata.py"],
["esphome/build_gen/platformio.py"],
# Workflow / harness files
["script/test_build_components.py"],
@@ -1071,6 +1075,9 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
# full list -- only esphome/build_gen/platformio.py is a trigger.
(["esphome/build_gen/espidf.py"], [], []),
# build_helpers modules the pch does not import are not triggers.
(["esphome/build_helpers/size_summary.py"], [], []),
(["esphome/build_helpers/ninja.py"], [], []),
# Docs / unrelated files -> empty.
(["README.md"], [], []),
([], [], []),
@@ -1842,3 +1842,53 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
)
assert sums[0] == sums[1]
def test_write_project_pch_strict_raises_on_skip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h"
)
with pytest.raises(EsphomeError, match="precedes the pch"):
_write_ninja(paths, ccache="/usr/bin/ccache")
def test_write_project_pch_strict_emits_probe_edge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict mode gates C++ src edges on a hard-failing load probe."""
paths = _make_framework(tmp_path)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "pchprobe" not in content
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "build esphome_pch.probe: pchprobe esphome_pch.h.gch" in content
assert "-Werror=invalid-pch" in content
# $out only expands in rule text; an edge-level binding would emit a
# bare stamp command and fail every strict build
assert "&& $python $buildtool touch $out" in content
assert "$stamp" not in content
# Strict consumers escalate: a per-TU rejection must red the build
assert "srccxxflags = -Winvalid-pch -Werror=invalid-pch" in content
assert "-Wno-error=invalid-pch" not in content
# With extra src flags the probe edge carries them like the .gch edge
CORE.platformio_options["build_src_flags"] = (
"-include esphome/components/esp8266/throw_stubs.h -DSRC_EXTRA"
)
content = _write_ninja(paths, ccache="/usr/bin/ccache")
assert "pchprobe esphome_pch.h.gch\n flags = " in content
edges = [
line
for line in content.splitlines()
if line.startswith("build obj/src/") and ".cpp.o:" in line
]
assert edges
assert all(line.endswith("| esphome_pch.h.gch esphome_pch.probe") for line in edges)
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
@@ -244,3 +245,20 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
):
assert build_tool.main() == 1
assert not dst.exists()
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
stamp = tmp_path / "esphome_pch.probe"
assert build_tool._run_touch(str(stamp)) == 0
assert stamp.is_file()
os.utime(stamp, (1, 1))
assert build_tool._run_touch(str(stamp)) == 0
assert stamp.stat().st_mtime > 1
def test_touch_reports_failure(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
missing_dir = tmp_path / "gone" / "stamp"
assert build_tool._run_touch(str(missing_dir)) == 1
assert "touch:" in capsys.readouterr().err
+306
View File
@@ -550,6 +550,9 @@ def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
gch = dev / "build" / "esphome_pch.h.gch"
def fake_compile(cmd, **kwargs):
if "-fsyntax-only" in cmd:
# The load probe follows a successful .gch build
return subprocess.CompletedProcess(cmd, 0, "", "")
# The compile must target the header, not the stub TU
assert cmd[-5:-3] == ["c++-header", "-c"]
gch.write_bytes(b"gch")
@@ -601,6 +604,11 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None
content = get_component_cmakelists()
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
assert "-Wno-error=invalid-pch" in content
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
strict_content = get_component_cmakelists()
assert "-Werror=invalid-pch" in strict_content
monkeypatch.delenv("ESPHOME_PCH_STRICT")
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "-include" not in get_component_cmakelists()
@@ -891,6 +899,125 @@ def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
assert len(calls) == 2
def test_prepare_pch_probe_spawn_failure_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A probe that cannot run discards the pch; strict raises."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_pf")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def probe_dies(cmd, **kwargs):
if "-fsyntax-only" in cmd:
raise OSError("probe spawn failed")
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=probe_dies),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
pytest.raises(EsphomeError, match="probe did not run"),
):
prepare_pch()
@pytest.mark.parametrize(
("stderr", "code"),
[("", -9), ("fatal: No space left on device", 1)],
ids=("signal-kill", "enospc"),
)
def test_prepare_pch_probe_environmental_failures_do_not_latch(
tmp_path: Path, stderr: str, code: int
) -> None:
"""A signal-killed or ENOSPC probe retries next build, no marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_pe")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def env_probe(cmd, **kwargs):
if "-fsyntax-only" in cmd:
return subprocess.CompletedProcess(cmd, code, "", stderr)
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=env_probe),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_signal_kill_strict_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ks")
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch(
"esphome.build_helpers.pch.subprocess.run",
side_effect=lambda cmd, **kw: subprocess.CompletedProcess(cmd, -9, "", ""),
),
pytest.raises(EsphomeError, match="killed by signal"),
):
prepare_pch()
def test_prepare_pch_strict_raises_when_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict must not read a disabled pch as success."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ds")
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
pytest.raises(EsphomeError, match="disabled"),
):
prepare_pch()
def test_prepare_pch_missing_sdkconfig_strict_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_ss")
(dev / "sdkconfig.test").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
pytest.raises(EsphomeError, match="sdkconfig unreadable"),
):
prepare_pch()
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
@@ -1103,3 +1230,182 @@ def test_prepare_pch_transient_compiler_failure_does_not_latch(
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
def test_prepare_pch_strict_raises_on_missing_db(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_st")
(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),
pytest.raises(EsphomeError, match="no usable compile command"),
):
prepare_pch()
def test_prepare_pch_probe_rejection_latches_and_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A toolchain that cannot load its own .gch discards it, latches the
marker, and fails strict mode."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_p")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def rejecting(cmd, **kwargs):
if "-fsyntax-only" in cmd:
if "-include" not in cmd:
# Baseline without the pch passes: the pch is to blame
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
)
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=rejecting),
):
prepare_pch()
assert not gch.exists()
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
(dev / "build" / "esphome_pch.h.gch.failed").unlink()
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
pytest.raises(EsphomeError, match="cannot load the pch"),
):
prepare_pch()
def test_prepare_pch_strict_reprobes_cached_gch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejection is per-process: strict must re-prove a cached .gch loads."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
dev = _make_pch_device(tmp_path, "dev_rc")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def ok(cmd, **kwargs):
if "-fsyntax-only" not in cmd:
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=ok),
):
prepare_pch()
assert gch.exists()
def reject(cmd, **kwargs):
assert "-fsyntax-only" in cmd, "cached path must not recompile"
if "-include" not in cmd:
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
)
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=reject),
pytest.raises(EsphomeError, match="cannot load the pch"),
):
prepare_pch()
assert not gch.exists()
# Per-process rejection may not reproduce: the cached path must not
# latch the pch off for later non-strict builds
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
def test_prepare_pch_unexpected_command_shape_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A tail the probe slice cannot trust discards and degrades."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_sh")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def ok(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=ok),
patch(
"esphome.build_helpers.pch.pch_compile_command",
return_value=(
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
dev / "build",
),
),
pytest.raises(EsphomeError, match="command shape"),
):
prepare_pch()
assert not gch.exists()
# Non-strict: same shape problem degrades without raising
monkeypatch.delenv("ESPHOME_PCH_STRICT")
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
patch(
"esphome.build_helpers.pch.pch_compile_command",
return_value=(
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
dev / "build",
),
),
):
prepare_pch()
assert not gch.exists()
def test_prepare_pch_probe_baseline_spawn_failure_is_transient(
tmp_path: Path,
) -> None:
"""A baseline that cannot spawn is environmental: no marker."""
from esphome.build_gen.espidf import prepare_pch
dev = _make_pch_device(tmp_path, "dev_bs")
CORE.build_path = dev
gch = dev / "build" / "esphome_pch.h.gch"
def flaky(cmd, **kwargs):
if "-fsyntax-only" in cmd:
if "-include" in cmd:
return subprocess.CompletedProcess(cmd, 1, "", "boom")
raise OSError("baseline spawn failed")
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=flaky),
):
prepare_pch()
assert not gch.exists()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
+163
View File
@@ -216,3 +216,166 @@ def test_ccache_pch_env_warns_on_falsy_extsum(
env = pch.ccache_pch_env()
assert "CCACHE_PCH_EXTSUM" not in env
assert "disables pch caching" in caplog.text
@pytest.mark.parametrize(
("value", "expected"),
[(None, False), ("0", False), ("1", True), ("true", True)],
)
def test_pch_strict(
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
if value is None:
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
else:
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
assert pch.pch_strict() is expected
def test_pch_cmake_consumer_substitutes_target_and_sources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
block = pch.pch_cmake_consumer("app", "${APP_SOURCES}")
assert "target_compile_options(app PRIVATE" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"' in block
assert "-Wno-error=invalid-pch" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in block
assert "set_source_files_properties(${APP_SOURCES} PROPERTIES" in block
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in block
# Placeholder guard: survives a build-system-side pristine wipe
assert 'file(TOUCH "${CMAKE_BINARY_DIR}/esphome_pch.h")' in block
def test_pch_cmake_consumer_strict_escalates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}")
def test_pch_cmake_consumer_empty_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert pch.pch_cmake_consumer("app", "${APP_SOURCES}") == ""
def test_guarded_prepare_logs_placeholder_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed placeholder touch must be traceable, not silent."""
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
def boom() -> None:
raise RuntimeError("boom")
# Missing build dir: the touch raises and only warns
pch.guarded_prepare(tmp_path / "missing", boom)
assert "Could not create the pch placeholder" in caplog.text
def test_pch_degraded_raises_only_in_strict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from esphome.core import EsphomeError
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
pch.pch_degraded("reason")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="reason"):
pch.pch_degraded("reason")
def test_pch_extra_scripts_strict_raises_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="disabled"):
pch.pch_extra_scripts()
def test_pch_strict_rejects_unrecognized_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A typo must not silently disable the gate."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "yolo")
with pytest.raises(EsphomeError, match="Unrecognized ESPHOME_PCH_STRICT"):
pch.pch_strict()
def test_discard_pch_raises_when_gch_survives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A .gch an unlink failure leaves behind would be consumed silently."""
from pathlib import Path as _P
from esphome.core import EsphomeError
(tmp_path / "esphome_pch.h").write_text("")
gch = tmp_path / "esphome_pch.h.gch"
gch.write_bytes(b"gch")
real_unlink = _P.unlink
def failing_unlink(self, missing_ok=False):
if self.name.endswith(".gch"):
raise OSError("readonly")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", failing_unlink)
with pytest.raises(EsphomeError, match="Could not discard"):
pch.discard_pch(tmp_path)
def test_discard_pch_raises_when_sum_survives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The .sum is ccache's pch identity; one that survives is as unsafe
as a surviving .gch."""
from pathlib import Path as _P
from esphome.core import EsphomeError
(tmp_path / "esphome_pch.h").write_text("")
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
(tmp_path / "esphome_pch.h.gch.sum").write_text("x")
real_unlink = _P.unlink
def failing_unlink(self, missing_ok=False):
if self.name.endswith(".sum"):
raise OSError("readonly")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", failing_unlink)
with pytest.raises(EsphomeError, match="Could not discard"):
pch.discard_pch(tmp_path)
def test_discard_pch_warns_when_file_vanished_concurrently(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unlink error on a file that is nonetheless gone only warns."""
from pathlib import Path as _P
(tmp_path / "esphome_pch.h").write_text("")
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
real_unlink = _P.unlink
def racing_unlink(self, missing_ok=False):
if self.name.endswith(".sum"):
# Racer removed it, then our unlink errored
raise OSError("stale handle")
return real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(_P, "unlink", racing_unlink)
pch.discard_pch(tmp_path)
assert "Could not discard the pch sidecars" in caplog.text
@@ -0,0 +1,43 @@
"""The pch script must reach PlatformIO builds only; the native ESP-IDF
toolchain has its own pch flow in build_gen/espidf.py."""
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.components import esp32
from esphome.const import Toolchain
from esphome.core import CORE
@pytest.mark.parametrize(
("toolchain", "copied"),
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
)
def test_copy_files_gates_pch_script_on_toolchain(
toolchain: Toolchain, copied: bool, tmp_path: Path
) -> None:
CORE.toolchain = toolchain
CORE.build_path = tmp_path
from esphome.components.esp32 import (
KEY_ESP32,
KEY_EXTRA_BUILD_FILES,
KEY_FLASH_SIZE,
)
with (
patch.object(esp32, "_write_sdkconfig") as write_sdkconfig,
patch.object(esp32, "_write_idf_component_yml"),
patch.object(esp32, "copy_pch_script") as copy_script,
patch.object(esp32, "write_file_if_changed"),
patch.object(esp32, "get_partition_csv"),
patch.dict(
CORE.data,
{KEY_ESP32: {KEY_EXTRA_BUILD_FILES: {}, KEY_FLASH_SIZE: "4MB"}},
),
):
esp32.copy_files()
# Proves execution reached (and passed) the gate on both rows
assert write_sdkconfig.called
assert copy_script.called is copied
@@ -0,0 +1,322 @@
"""nrf52 sdk-nrf pch wiring: the CMake consumer block, the prepare wrapper,
and the two-phase west split in run_compile."""
from collections.abc import Generator
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from esphome.components import nrf52
from esphome.components.zephyr.const import KEY_BOARD
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def pch_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
@pytest.fixture
def build_dir(tmp_path: Path) -> Path:
d = tmp_path / "build" / ".pioenvs" / "livingroom"
d.mkdir(parents=True)
return d
_AUTOCONF_TEXT = "#define CONFIG_GPIO 1\n"
def _write_autoconf(build_dir: Path) -> Path:
autoconf = build_dir / "zephyr" / "include" / "generated" / "zephyr" / "autoconf.h"
autoconf.parent.mkdir(parents=True)
autoconf.write_text(_AUTOCONF_TEXT)
return autoconf
def test_prepare_pch_disabled_discards_and_degrades(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
gch = build_dir / "esphome_pch.h.gch"
gch.write_bytes(b"x")
nrf52._prepare_pch(build_dir)
assert not gch.exists()
def test_prepare_pch_disabled_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_missing_autoconf_degrades(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "No autoconf.h found" in caplog.text
# The header is written first so OBJECT_DEPENDS stays satisfied
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_pch_missing_autoconf_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="autoconf.h missing"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_unreadable_autoconf_fails_closed(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A directory named autoconf.h: read_text raises OSError
autoconf = build_dir / "zephyr" / "include" / "generated" / "autoconf.h"
autoconf.mkdir(parents=True)
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "Could not read" in caplog.text
def test_app_build_dir_sysbuild_layout(build_dir: Path) -> None:
app = build_dir / "zephyr"
app.mkdir()
(app / "CMakeCache.txt").write_text("")
assert nrf52._app_build_dir(build_dir) == app
def test_app_build_dir_top_level_layout(build_dir: Path) -> None:
# Non-sysbuild: build_dir/zephyr is the Zephyr output dir, no cache
(build_dir / "zephyr").mkdir()
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_ignores_cache_directory(build_dir: Path) -> None:
(build_dir / "zephyr" / "CMakeCache.txt").mkdir(parents=True)
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_propagates_stat_errors(build_dir: Path) -> None:
# is_file() would swallow this and mislocate the pch
with (
patch.object(Path, "stat", side_effect=PermissionError("denied")),
pytest.raises(PermissionError),
):
nrf52._app_build_dir(build_dir)
def test_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None:
_write_autoconf(build_dir)
with (
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}),
patch.object(
nrf52, "zephyr_data", return_value={KEY_BOARD: "adafruit_feather"}
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52.pch, "prepare_pch") as prepare,
):
nrf52._prepare_pch(build_dir)
assert (build_dir / "esphome_pch.h").is_file()
(passed_dir, headers, extras) = prepare.call_args.args
assert passed_dir == build_dir
assert headers == nrf52.PCH_DEFAULT_HEADERS
assert list(extras) == ["2.9.2", "adafruit_feather", _AUTOCONF_TEXT, "-Os"]
def _generate_cmake(tmp_path: Path) -> str:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
with (
patch(
"esphome.components.zephyr.library.generate_zephyr_modules",
return_value=[],
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52, "get_project_link_flags", return_value=[]),
):
nrf52._generate_cmake_lists()
return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text()
def test_cmake_lists_include_pch_consumer_block(tmp_path: Path) -> None:
# Content contract is pinned by the shared pch_cmake_consumer tests;
# here only that the block reaches the generated CMakeLists
text = _generate_cmake(tmp_path)
assert "target_compile_options(app PRIVATE" in text
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in text
def test_cmake_lists_pch_block_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "esphome_pch.h" not in _generate_cmake(tmp_path)
CompileCtx = tuple[Mock, Mock, Path]
class TestRunCompilePhases:
"""The pch pre-build block in run_compile: header write, conditional
cmake-only phase, and the never-abort-the-build exception contract."""
@pytest.fixture
def compile_ctx(self, tmp_path: Path) -> Generator[CompileCtx, None, None]:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
CORE.toolchain = Toolchain.SDK_NRF
with (
patch.object(nrf52, "check_and_install"),
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
patch.object(
nrf52,
"get_build_paths",
return_value={
"python_executable": "python3",
"framework_path": tmp_path,
},
),
patch.object(nrf52, "get_build_env", return_value={}),
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
patch.object(nrf52, "run_command_ok") as run_cmd,
patch.object(nrf52, "_prepare_pch") as prepare,
):
yield run_cmd, prepare, CORE.relative_pioenvs_path(CORE.name)
def _run(self) -> None:
nrf52.run_compile(None, {})
def test_missing_db_runs_cmake_phase(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, build_dir = compile_ctx
# cmake-only ok, generated headers ok, final build fails
results = iter([True, True, False])
def west(cmd, **kwargs):
# Phase 1 configures the sysbuild app domain
app = build_dir / "zephyr"
app.mkdir(parents=True, exist_ok=True)
(app / "CMakeCache.txt").write_text("")
return next(results)
run_cmd.side_effect = west
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "--cmake-only" in run_cmd.call_args_list[0].args[0]
# Generated syscall headers are built in the app domain pre-pch
headers_cmd = run_cmd.call_args_list[1].args[0]
assert headers_cmd[:2] == ["cmake", "--build"]
assert str(build_dir / "zephyr") in headers_cmd
assert "zephyr_generated_headers" in headers_cmd
assert "--cmake-only" not in run_cmd.call_args_list[2].args[0]
# The pch is prepared in the app domain dir, not the sysbuild root
assert prepare.call_args.args[0] == build_dir / "zephyr"
def test_generated_headers_failure_degrades(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, _ = compile_ctx
# headers target fails, the real build still runs (and fails here)
run_cmd.side_effect = [True, False, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "Zephyr header generation failed" in caplog.text
# The doomed .gch compile is skipped: it would latch .gch.failed
assert not prepare.called
def test_generated_headers_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [True, False]
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
self._run()
assert not prepare.called
def test_cmake_phase_failure_raises(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="configure failed"):
self._run()
assert not prepare.called
def test_ccache_pch_env_reaches_west(self, compile_ctx: CompileCtx) -> None:
run_cmd, _, _ = compile_ctx
run_cmd.side_effect = [False]
# clear=True also drops ambient CCACHE_*/ESPHOME_PCH_* overrides
with (
patch.dict("os.environ", {}, clear=True),
pytest.raises(EsphomeError, match="configure failed"),
):
self._run()
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_PCH_EXTSUM"] == "true"
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
@pytest.mark.parametrize("sysbuild", [False, True])
def test_settled_db_skips_cmake_phase(
self, sysbuild: bool, compile_ctx: CompileCtx
) -> None:
run_cmd, prepare, build_dir = compile_ctx
app = build_dir / "zephyr" if sysbuild else build_dir
app.mkdir(parents=True)
# A present top-level cache keeps the pristine wipe from dropping
# the DB; the app-dir cache is the sysbuild layout marker
(build_dir / "CMakeCache.txt").write_text("")
(app / "CMakeCache.txt").write_text("")
(app / "compile_commands.json").write_text("[]")
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert "--cmake-only" not in run_cmd.call_args.args[0]
assert prepare.call_args.args[0] == app
def test_disabled_skips_header_and_cmake_phase(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
run_cmd, prepare, build_dir = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert not (build_dir / "esphome_pch.h").exists()
# The wrapper still runs: it discards stale sidecars and feeds strict
assert prepare.called
def test_prepare_failure_never_aborts_the_build(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, build_dir = compile_ctx
build_dir.mkdir(parents=True)
# Keep the pristine wipe from dropping the dir the fallback touches
(build_dir / "CMakeCache.txt").write_text("")
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 3
assert "Precompiled header setup failed" in caplog.text
# The fallback still satisfies OBJECT_DEPENDS
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True]
with pytest.raises(RuntimeError, match="boom"):
self._run()
@@ -1,7 +1,8 @@
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
"""Minimal idf_tools stand-in for the espidf helper-script tests."""
from collections.abc import Iterable
import os
import pathlib
CURRENT_PLATFORM = "linux-amd64"
TOOLS_FILE = "tools/tools.json"
@@ -54,6 +55,7 @@ class _Tool:
installed: Iterable[str] = (),
broken: bool = False,
) -> None:
self.name = "" # filled in from the _TOOLS key below
self.versions = versions
self._recommended = recommended
self.versions_installed = list(installed)
@@ -69,6 +71,19 @@ class _Tool:
if self._broken:
raise ToolBinaryError("broken binary")
def get_path_for_version(self, version: str) -> str:
return str(pathlib.Path(g.idf_tools_path) / "tools" / self.name / version)
def install(self, version: str) -> None:
dest = pathlib.Path(self.get_path_for_version(version))
dest.mkdir(exist_ok=True, parents=True)
if self.name in os.environ.get("TEST_FAIL_INSTALL", "").split(","):
# Fail mid-install like a torn unpack: the partial dir is left
# behind and check_binary_valid's failure path exits the process
(dest / ".partial").write_text("torn", encoding="utf-8")
raise SystemExit(1)
(dest / ".installed").write_text("ok", encoding="utf-8")
_TOOLS = {
"cmake": _Tool(
@@ -97,8 +112,17 @@ _TOOLS = {
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
}
for _name, _tool in _TOOLS.items():
_tool.name = _name
def load_tools_info() -> dict[str, _Tool]:
# Test hook: strip verification metadata from the named tools
for name in os.environ.get("TEST_NO_SHA", "").split(","):
if (tool := _TOOLS.get(name)) is not None:
for version in tool.versions.values():
if (download := version.get_download_for_platform("")) is not None:
download.sha256 = ""
return _TOOLS
+19 -18
View File
@@ -57,7 +57,7 @@ def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
with (
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
patch.object(framework, "install_package") as mock_install,
patch.object(framework, "install_packages") as mock_install,
patch.object(framework, "prefetch_packages") as mock_prefetch,
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
):
@@ -65,26 +65,27 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None:
assert paths.framework == tmp_path / "frameworks" / "3.30102.0"
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
assert paths.ninja == tmp_path / "ninja"
assert mock_install.call_count == 2
# Full argument pinning: a copy-paste swap between the two near-identical
# calls (mirrors, destination) must not stay green
fw_call, tc_call = mock_install.call_args_list
assert fw_call.args == (
framework.FRAMEWORK_PACKAGE,
"3.30102.0",
tmp_path / "frameworks" / "3.30102.0",
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
# specs (mirrors, destination) must not stay green
assert mock_install.call_args.args == (
(
(
framework.FRAMEWORK_PACKAGE,
"3.30102.0",
tmp_path / "frameworks" / "3.30102.0",
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
("cores/esp8266", "tools/sdk", "libraries"),
),
(
framework.TOOLCHAIN_PACKAGE,
framework.TOOLCHAIN_VERSION,
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
("bin", "xtensa-lx106-elf"),
),
),
tmp_path / "downloads",
)
assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries")
assert tc_call.args == (
framework.TOOLCHAIN_PACKAGE,
framework.TOOLCHAIN_VERSION,
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
tmp_path / "downloads",
)
assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf")
# The prefetch sees the same package specs as the installs
assert mock_prefetch.call_args.args == (
[
+10
View File
@@ -568,6 +568,16 @@ class TestEsphomeCore:
target.config_path = Path("foo/config")
return target
def test_add_platformio_option_merges_string_into_list(self, target) -> None:
"""A user platformio_options string lands after a component's list
(FINAL priority) and must merge as one element, not assert."""
target.add_platformio_option("extra_scripts", ["post:pch.py"])
target.add_platformio_option("extra_scripts", "pre:mine.py")
assert target.platformio_options["extra_scripts"] == [
"post:pch.py",
"pre:mine.py",
]
def test_reset(self, target):
"""Call reset on target and compare to new instance"""
other = core.EsphomeCore().__dict__
+307 -21
View File
@@ -37,6 +37,7 @@ from esphome.espidf.framework import (
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_preinstall_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
@@ -403,6 +404,7 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"),
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
patch("esphome.espidf.framework._preinstall_idf_tool_archives"),
patch("esphome.espidf.framework._write_stamp"),
patch("esphome.espidf.framework._check_stamp", return_value=True),
patch("esphome.espidf.framework._stamp_covers", return_value=True),
@@ -1051,11 +1053,16 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
assert download.call_count == 6
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
def test_prefetch_reverifies_already_downloaded_archives(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-existing archive is not skipped: download_with_resume keeps it
only when the sha256 matches, so the pre-extraction can trust it."""
dist = get_idf_tools_path() / "dist"
dist.mkdir(parents=True)
(dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached")
with (
caplog.at_level(logging.INFO),
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
@@ -1065,9 +1072,12 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
# only the missing archive is downloaded
assert download.call_count == 1
assert download.call_args[0][1] == dist / "ninja.zip"
assert sorted(call[0][1] for call in download.call_args_list) == [
dist / "cmake-3.30.2.tar.gz",
dist / "ninja.zip",
]
# The log distinguishes verifying cached archives from real downloads
assert "Downloading 2 ESP-IDF tool archive(s) (1 cached, verifying)" in caplog.text
@pytest.mark.parametrize(
@@ -1188,30 +1198,41 @@ def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
cmd = run.call_args[0][0]
assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"]
assert cmd[1].endswith("get_tool_downloads.py")
# the script inherits the caller's env plus the framework tools PYTHONPATH
# the script inherits the caller's env plus an explicit PYTHONPATH:
# sibling scripts, the esphome package root, the framework's idf_tools
env = run.call_args[1]["env"]
assert env["IDF_TOOLS_PATH"] == "/x"
assert env["PYTHONPATH"] == str(tmp_path / "tools")
assert env["PYTHONPATH"] == os.pathsep.join(
(
str(_ESPIDF_SCRIPTS_DIR),
str(_ESPIDF_SCRIPTS_DIR.parents[1]),
str(tmp_path / "tools"),
)
)
def test_framework_install_prefetches_before_installer(
espidf_mocks: SimpleNamespace,
) -> None:
"""The prefetch runs before idf_tools.py install so the installer finds
the archives already in dist/."""
"""The prefetch downloads and the pre-extraction both run before
idf_tools.py install so the installer finds the tools in place."""
calls: list[str] = []
with (
patch(
"esphome.espidf.framework._prefetch_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("prefetch"),
),
patch(
"esphome.espidf.framework._preinstall_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("preinstall"),
),
):
espidf_mocks.run_ok.side_effect = lambda *a, **k: (
calls.append("install") or True
)
check_esp_idf_install(_IDF_VERSION, force=True)
assert calls.index("prefetch") < calls.index("install")
assert calls.index("prefetch") < calls.index("preinstall") < calls.index("install")
# ---------------------------------------------------------------------------
@@ -1220,13 +1241,16 @@ def test_framework_install_prefetches_before_installer(
_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub"
_ESPIDF_SCRIPTS_DIR = Path(__file__).parents[2] / "esphome" / "espidf"
def _run_downloads_script(
tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None
def _run_espidf_script(
tmp_path: Path,
script_name: str,
*args: str,
env_extra: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run the real get_tool_downloads.py against the stub idf_tools module."""
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
"""Run a real espidf helper script against the stub idf_tools module."""
env = os.environ | {
"PYTHONPATH": str(_IDF_TOOLS_STUB_DIR),
"IDF_TOOLS_PATH": str(tmp_path / "tp"),
@@ -1234,7 +1258,12 @@ def _run_downloads_script(
if env_extra:
env |= env_extra
return subprocess.run(
[sys.executable, str(script), str(tmp_path / "fw"), *args],
[
sys.executable,
str(_ESPIDF_SCRIPTS_DIR / script_name),
str(tmp_path / "fw"),
*args,
],
capture_output=True,
text=True,
env=env,
@@ -1246,7 +1275,7 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
"""Installed versions are skipped, tools that fail their binary check are
still listed, rename_dist decides the dist filename, and idf_tools' stdout
chatter stays off the JSON channel."""
result = _run_downloads_script(tmp_path, "esp32", "required")
result = _run_espidf_script(tmp_path, "get_tool_downloads.py", "esp32", "required")
assert result.returncode == 0, result.stderr
downloads = {d["name"]: d for d in json.loads(result.stdout)}
@@ -1262,8 +1291,9 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
result = _run_downloads_script(
result = _run_espidf_script(
tmp_path,
"get_tool_downloads.py",
"esp32",
"required",
env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"},
@@ -1274,13 +1304,13 @@ def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
assert all(d["url"].startswith("https://mirror.test/") for d in downloads)
def _run_downloads_inprocess(
def _run_espidf_script_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
script_name: str,
*args: str,
) -> list[dict]:
"""Execute get_tool_downloads.py in-process against the stub idf_tools.
) -> None:
"""Execute an espidf helper script in-process against the stub idf_tools.
Unlike the subprocess variant this runs under coverage, exercising the
script's own lines.
@@ -1291,10 +1321,24 @@ def _run_downloads_inprocess(
stub = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stub)
monkeypatch.setitem(sys.modules, "idf_tools", stub)
# _tool_resolution binds idf_tools objects at import; force a fresh
# import against this test's stub instance
monkeypatch.delitem(sys.modules, "_tool_resolution", raising=False)
# python <script> puts the script's directory on sys.path; runpy does not
monkeypatch.syspath_prepend(str(_ESPIDF_SCRIPTS_DIR))
monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp"))
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
script = _ESPIDF_SCRIPTS_DIR / script_name
monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args])
runpy.run_path(str(script))
def _run_downloads_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
*args: str,
) -> list[dict]:
_run_espidf_script_inprocess(tmp_path, monkeypatch, "get_tool_downloads.py", *args)
return json.loads(capsys.readouterr().out)
@@ -2008,3 +2052,245 @@ def test_check_windows_path_length_long_path_warns(
assert "long path support" in message
# The install is global now; the remedy is the prefix env, not moving the project.
assert "ESPHOME_ESP_IDF_PREFIX" in message
# ---------------------------------------------------------------------------
# _preinstall_idf_tool_archives
# ---------------------------------------------------------------------------
def test_preinstall_streams_script_with_workers(tmp_path: Path) -> None:
"""The pre-extraction streams install_tool_archives.py with the worker
count and the same targets/tools the installer will get."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(True, None, None),
) as run_script,
patch("esphome.espidf.framework.get_usable_cpu_count", return_value=3),
):
_preinstall_idf_tool_archives(
tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "x"}
)
run_script.assert_called_once_with(
tmp_path,
"install_tool_archives.py",
"ESP-IDF tool archive extraction",
args=["esp32,esp32c3", "3", "required", "cmake"],
env={"IDF_TOOLS_PATH": "x"},
stream_output=True,
)
def test_preinstall_caps_workers(tmp_path: Path) -> None:
"""A high core count is capped; the workers share one disk."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(True, None, None),
) as run_script,
patch("esphome.espidf.framework.get_usable_cpu_count", return_value=64),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert run_script.call_args.kwargs["args"][1] == "10"
def test_preinstall_script_failure_only_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed pre-extraction leaves the install to the sequential path."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(False, None, None),
),
patch("esphome.espidf.framework.get_usable_cpu_count", return_value=1),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert "pre-extraction failed" in caplog.text
def test_preinstall_exception_only_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unexpected error must not become a new way for the install to fail,
and keeps its traceback at WARNING."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
side_effect=TypeError("bad call"),
),
patch("esphome.espidf.framework.get_usable_cpu_count", return_value=1),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
record = next(r for r in caplog.records if "pre-extraction failed" in r.message)
assert record.exc_info is not None
# ---------------------------------------------------------------------------
# install_tool_archives.py (against the stub idf_tools module in fixtures/)
# ---------------------------------------------------------------------------
def _make_dist(tmp_path: Path, *names: str) -> None:
dist = tmp_path / "tp" / "dist"
dist.mkdir(parents=True, exist_ok=True)
for name in names:
(dist / name).write_bytes(b"x")
def test_install_tool_archives_extracts_pending_in_parallel(tmp_path: Path) -> None:
"""Subprocess end-to-end: tools with a prefetched archive install
concurrently; installed and broken tools stay with the installer."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
result = _run_espidf_script(
tmp_path, "install_tool_archives.py", "esp32", "8", "required"
)
assert result.returncode == 0, result.stderr
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
assert not (tools / "installed-tool").exists()
assert not (tools / "broken-tool").exists()
# The worker count clamps to the pending count
assert (
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
"cmake@3.30.2, ninja@1.12.1" in result.stdout
)
assert "leaving broken broken-tool to the installer" in result.stderr
def test_install_tool_archives_single_pending_stays_sequential(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""One pending archive has nothing to parallelize; the installer keeps
its normal output."""
_make_dist(tmp_path, "cmake.tar.gz")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert not (tmp_path / "tp" / "tools").exists()
out = capsys.readouterr().out
assert "Extracting" not in out
# A resolution drift that empties pending stays observable
assert "1 of 2 uninstalled tool(s) have a prefetched archive" in out
def test_install_tool_archives_failed_install_left_to_installer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A per-tool failure warns, removes the torn dest dir so the installer
cannot trust it, and moves on; the other tools still install."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert not (tools / "ninja" / "1.12.1").exists()
err = capsys.readouterr().err
assert "pre-extracting ninja@1.12.1 failed" in err
assert "1 of 2 pre-extractions failed" in err
def test_install_tool_archives_all_failed_exits_nonzero(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Every job failing is a systematic fault; the nonzero exit lets the
caller log it."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "cmake,ninja")
with pytest.raises(SystemExit) as excinfo:
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert excinfo.value.code == 1
assert "2 of 2 pre-extractions failed" in capsys.readouterr().err
tools = tmp_path / "tp" / "tools"
assert not (tools / "cmake" / "3.30.2").exists()
assert not (tools / "ninja" / "1.12.1").exists()
def test_install_tool_archives_inprocess_dedupes_and_skips(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""In-process full flow: duplicate tool@version specs collapse to one
job, broken and installed tools are skipped, both pending tools install."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
_run_espidf_script_inprocess(
tmp_path,
monkeypatch,
"install_tool_archives.py",
"esp32",
"8",
"cmake",
"ninja",
"cmake@3.30.2",
"installed-tool",
"broken-tool",
)
captured = capsys.readouterr()
assert (
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
"cmake@3.30.2, ninja@1.12.1" in captured.out
)
assert "extracted cmake@3.30.2" in captured.out
assert "extracted ninja@1.12.1" in captured.out
assert "leaving broken broken-tool to the installer" in captured.err
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
assert not (tools / "installed-tool").exists()
assert not (tools / "broken-tool").exists()
def test_install_tool_archives_surviving_torn_dir_escalates(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A torn dir that survives cleanup could fool the installer; the exit
is nonzero even though the other tool succeeded."""
import esphome.helpers
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
monkeypatch.setattr(
esphome.helpers, "rmtree", MagicMock(side_effect=OSError("busy"))
)
with pytest.raises(SystemExit) as excinfo:
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert excinfo.value.code == 1
err = capsys.readouterr().err
assert "could not remove" in err
assert "1 of 2 pre-extractions failed" in err
assert (tmp_path / "tp" / "tools" / "cmake" / "3.30.2" / ".installed").is_file()
def test_install_tool_archives_skips_unverifiable_archives(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""An entry the prefetch could not verify is never extracted, even with
an archive on disk."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_NO_SHA", "cmake,ninja")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert not (tmp_path / "tp" / "tools").exists()
assert "0 of 2 uninstalled tool(s) have a prefetched archive" in (
capsys.readouterr().out
)
+46
View File
@@ -669,6 +669,52 @@ def test_get_core_framework_version_from_core_data():
assert toolchain._get_core_framework_version() == "5.5.4"
def test_run_compile_aborts_when_stale_pch_survives_discard(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An undiscardable stale .gch means silently wrong output: abort."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
_setup_build(setup_core)
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")),
patch(
"esphome.build_helpers.pch.discard_pch",
side_effect=EsphomeError("Could not discard the stale precompiled header"),
),
pytest.raises(EsphomeError, match="Could not discard"),
):
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
def test_run_compile_strict_reraises_pch_failure(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_PCH_STRICT must reach through the real compile flow."""
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
_setup_build(setup_core)
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=EsphomeError("ESPHOME_PCH_STRICT: no usable compile command"),
),
pytest.raises(EsphomeError, match="no usable compile command"),
):
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
def test_run_compile_invokes_prepare_pch_and_survives_failure(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+73 -4
View File
@@ -523,6 +523,17 @@ class TestArchiveExtractAll:
archive_extract_all(archive, dest)
assert (dest / "file.txt").read_text() == "hi"
def test_progress_callback_passed_through(self, tmp_path: Path) -> None:
"""The progress kwarg reaches the dispatched extractor."""
archive = tmp_path / "test.tar.gz"
archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"}))
dest = tmp_path / "out"
dest.mkdir()
fractions: list[float] = []
archive_extract_all(archive, dest, progress=fractions.append)
assert fractions[-1] == 1
assert (dest / "file.txt").read_bytes() == b"hello"
def test_invalid_type_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="archive must be"):
archive_extract_all(42, ".") # type: ignore[arg-type]
@@ -1951,6 +1962,19 @@ class TestTarExtractAllBranches:
mock_pb.assert_called_once_with("Extracting")
mock_pb.return_value.update.assert_called()
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
"""A progress callback wins over progress_header and ends at 1.0."""
buf = _make_tar([_reg("a.txt"), _reg("b.txt")], {"a.txt": b"x", "b.txt": b"y"})
fractions: list[float] = []
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
_tar_extract_all(
buf, tmp_path, progress_header="Extracting", progress=fractions.append
)
mock_pb.assert_not_called()
assert fractions == sorted(fractions)
assert fractions[-1] == 1
assert (tmp_path / "a.txt").is_file()
# ---------------------------------------------------------------------------
# _zip_extract_all — additional branch coverage
@@ -1980,6 +2004,19 @@ class TestZipExtractAllBranches:
mock_pb.assert_called_once_with("Unzipping")
mock_pb.return_value.update.assert_called()
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
"""A progress callback wins over progress_header and ends at 1.0."""
buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")])
fractions: list[float] = []
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
_zip_extract_all(
buf, tmp_path, progress_header="Unzipping", progress=fractions.append
)
mock_pb.assert_not_called()
assert fractions == sorted(fractions)
assert fractions[-1] == 1
assert (tmp_path / "a.txt").is_file()
# ---------------------------------------------------------------------------
# _rename_with_retry
@@ -2137,6 +2174,20 @@ class TestSevenZipExtractAll:
mock_pb.assert_called_once_with("Unpacking 7z")
mock_pb.return_value.update.assert_called()
def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None:
"""A progress callback wins over progress_header; 7z reports 1.0 once."""
buf = self._make_7z({"file.txt": b"x"})
out = tmp_path / "out"
out.mkdir()
fractions: list[float] = []
with patch("esphome.framework_helpers.ProgressBar") as mock_pb:
_7z_extract_all(
buf, out, progress_header="Unpacking 7z", progress=fractions.append
)
mock_pb.assert_not_called()
assert fractions == [1]
assert (out / "file.txt").is_file()
def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None:
"""Names that resolve as absolute are silently skipped."""
import py7zr
@@ -2294,18 +2345,36 @@ def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
)
def test_warn_prefetch_failures_names_each_failure(
def test_warn_batch_failures_names_each_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The shared failure loop warns per job with the failure reason."""
from esphome.framework_helpers import warn_prefetch_failures
from esphome.framework_helpers import warn_batch_failures
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
warn_batch_failures([("toolchain-x@1", OSError("down"))])
assert "Could not prefetch toolchain-x@1: down" in caplog.text
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
warn_batch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
assert "Prefetch of lib failed: gone" in caplog.text
def test_warn_batch_failures_unexpected_error_keeps_traceback(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unexpected error type is not reduced to a bare message; expected
download failures stay message-only at WARNING."""
from esphome.framework_helpers import warn_batch_failures
with caplog.at_level(logging.DEBUG):
warn_batch_failures(
[("pkg", TypeError("bad call")), ("lib", OSError("down"))],
"Could not install %s: %s",
)
warnings = {r.getMessage(): r for r in caplog.records if r.levelname == "WARNING"}
assert warnings["Could not install pkg: bad call"].exc_info is not None
assert warnings["Could not install lib: down"].exc_info is None
assert "Failure detail" in caplog.text
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
+21
View File
@@ -0,0 +1,21 @@
"""The pch prefix must keep resolving; a rename would silently
collapse the precompiled set to defines.h with strict CI still green."""
from pathlib import Path
import re
from esphome.build_helpers.pch import PCH_PREFIX_HEADER
REPO = Path(__file__).parents[2]
def test_pch_prefix_resolves() -> None:
prefix = REPO / PCH_PREFIX_HEADER
assert prefix.is_file()
body = prefix.read_text()
includes = re.findall(r'#include "([^"]+)"', body)
assert includes, "prefix wrapper folds nothing"
for name in includes:
assert (REPO / name).is_file(), f"{name} does not resolve"
# The C guard is what keeps build_src_flags safe on C/assembly edges
assert "#ifdef __cplusplus" in body
+125 -4
View File
@@ -100,12 +100,14 @@ def _fake_cxx(
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'
body += 'out=""; prev=""; mf=0; dep=0; inc=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; [ "$a" = "-include" ] && inc=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'
# -Werror=invalid-pch makes rejection a nonzero exit; the
# baseline (no -include) still passes
body += 'case " $* " in *c++-header*) ;; *) if [ "$inc" = 1 ]; then echo "error: esphome_pch.h.gch: had text segment at different address" >&2; exit 1; fi;; 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)
@@ -293,13 +295,17 @@ def test_pch_script_spawn_failure_is_transient(
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."""
def test_pch_script_probe_baseline_failure_latches_with_honest_label(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Probe AND baseline failing is a deterministic environment problem:
latch, but blame the environment rather than the pch."""
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 == []
assert "probe cannot run at all" in capsys.readouterr().out
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
@@ -458,3 +464,118 @@ def test_pch_script_unreadable_local_header_skips_pch(
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "skipping precompiled header" in capsys.readouterr().out
def test_pch_script_rejects_unrecognized_strict_value(tmp_path: Path) -> None:
"""A typo'd knob must fail the build, not silently disable the gate."""
with pytest.raises(RuntimeError, match="Unrecognized ESPHOME_PCH_STRICT"):
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "yolo"})
def test_pch_script_strict_tables_match_helpers(tmp_path: Path) -> None:
"""The script's mirrored spelling tables must not drift."""
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
# Hermetic: the constants are module-level, but the exec still runs
# _setup_pch, which must not touch the host toolchain
cxx = _fake_cxx(tmp_path)
env = _FakeSConsEnv(proj, proj / "src", str(cxx), ["-DX=1"])
namespace = {"Import": lambda *_names: None, "env": env, "projenv": env}
with patch.dict(os.environ, {}, clear=True):
exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102
assert set(namespace["_TRUTHY"]) == set(TRUTHY_ENV_STRINGS)
# parse_enable_env handles the empty string separately
assert set(namespace["_FALSY"]) - {""} == set(FALSY_ENV_STRINGS)
def test_pch_script_strict_reprobes_cached_gch(tmp_path: Path) -> None:
"""Rejection is per-process: strict re-proves a cached .gch loads."""
_run_script(tmp_path)
proj = tmp_path / "dev"
assert (proj / "esphome_pch.h.gch").is_file()
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
# Second run: cache fresh, but the toolchain now rejects loads
with pytest.raises(RuntimeError, match="not used"):
_run_script(tmp_path, reject_pch=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
assert not (proj / "esphome_pch.h.gch").exists()
def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None:
"""ESPHOME_PCH_STRICT fails the build instead of degrading."""
with pytest.raises(RuntimeError, match="ESPHOME_PCH_STRICT"):
_run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
def test_pch_script_strict_fails_without_scons(tmp_path: Path) -> None:
"""No SCons under PlatformIO is an anomaly; strict must not pass."""
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'")
import sys
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
with (
# None forces ImportError even where SCons is installed
patch.dict(sys.modules, {"SCons.Script": None}),
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
pytest.raises(RuntimeError, match="not used"),
):
exec( # noqa: S102
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
def test_pch_script_strict_reraises_internal_errors(tmp_path: Path) -> None:
"""The catch-all must not swallow programming errors in strict mode."""
with pytest.raises(TypeError):
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}, platform_cls=None)
@pytest.mark.parametrize(("targets", "passes"), [(["nobuild"], True), ([], False)])
def test_pch_script_strict_projenv_skip_gated_on_nobuild(
tmp_path: Path, targets: list[str], passes: bool
) -> None:
"""-t nobuild compiles nothing, so the skip passes strict; a missing
projenv on a real compile must not."""
import sys
import types
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'")
scons = types.ModuleType("SCons")
scons_script = types.ModuleType("SCons.Script")
scons_script.COMMAND_LINE_TARGETS = targets
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
with (
patch.dict(sys.modules, {"SCons": scons, "SCons.Script": scons_script}),
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
):
run = lambda: exec( # noqa: S102, E731
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env},
)
if passes:
run()
else:
with pytest.raises(RuntimeError, match="not used"):
run()
assert not (proj / "esphome_pch.h").exists()
def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None:
scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"})
# Strict escalates the consumer edges too
assert "-Werror=invalid-pch" in scons_env.prepended
assert "-Wno-error=invalid-pch" not in scons_env.prepended
@@ -1803,3 +1803,20 @@ def test_platformio_private_api_contract() -> None:
derived = PackageSpec("https://x/y/archive/master.zip")
assert derived.name and not derived.has_custom_name()
assert PackageSpec("Foo=https://x/y/archive/master.zip").has_custom_name()
def test_preinstall_caps_workers(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A high core count is capped; the workers share one disk."""
with (
caplog.at_level(logging.INFO),
patch.object(pf, "get_usable_cpu_count", return_value=64),
):
pf._preinstall(
_fake_manager(tmp_path),
[(f"p{i}@1", _FakeSpec(name=f"p{i}")) for i in range(11)],
)
assert "Installing 11 PlatformIO package(s) with 10 extraction worker(s)" in (
caplog.text
)
+258 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from contextlib import contextmanager
import json
import logging
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -307,7 +308,11 @@ def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
)
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
assert mock_download.call_args[1] == {
"sha256": "abc123",
"size": 42,
"progress": None,
}
def test_install_package_validates_expected_layout(tmp_path: Path) -> None:
@@ -723,3 +728,255 @@ def test_prefetch_packages_unexpected_failure_warns(
tmp_path / "dl",
)
assert "TypeError" in caplog.text
def _spec(name: str, version: str, dest: Path, mirrors=None, expect=("payload",)):
return (name, version, dest, mirrors or [], expect)
def test_install_packages_extracts_verified_archives_in_parallel(
tmp_path: Path,
) -> None:
"""Two prefetched archives install concurrently under one shared bar."""
dl = tmp_path / "dl"
dl.mkdir()
(dl / "a-1.0").write_bytes(b"x" * 10)
(dl / "b-2.0").write_bytes(b"y" * 20)
with patch.object(registry, "install_package") as mock_install:
registry.install_packages(
[_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl
)
assert mock_install.call_count == 2
calls = sorted(mock_install.call_args_list, key=lambda c: c[0][0])
for c, (name, version) in zip(calls, [("a", "1.0"), ("b", "2.0")], strict=True):
assert c[0][:3] == (name, version, tmp_path / name)
assert c[1]["expect"] == ("payload",)
assert callable(c[1]["extract_progress"])
# Driving the tracker exercises the fraction-to-bytes scaling
c[1]["extract_progress"](0.5)
c[1]["extract_progress"](1.0)
def test_install_packages_single_archive_stays_sequential(tmp_path: Path) -> None:
"""One verified archive has nothing to parallelize; original order kept."""
dl = tmp_path / "dl"
dl.mkdir()
(dl / "a-1.0").write_bytes(b"x")
specs = [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")]
with patch.object(registry, "install_package") as mock_install:
registry.install_packages(specs, dl)
assert [c[0][0] for c in mock_install.call_args_list] == ["a", "b"]
for c in mock_install.call_args_list:
assert "extract_progress" not in c[1]
def test_install_packages_mirror_and_marker_stay_sequential(tmp_path: Path) -> None:
"""Mirror overrides and marker hits never enter the parallel batch."""
dl = tmp_path / "dl"
dl.mkdir()
for name, ver in (("a", "1.0"), ("b", "2.0"), ("c", "3.0"), ("d", "4.0")):
(dl / f"{name}-{ver}").write_bytes(b"x")
marked = tmp_path / "c"
marked.mkdir()
(marked / ".esphome_extracted").touch()
specs = [
_spec("a", "1.0", tmp_path / "a"),
_spec("b", "2.0", tmp_path / "b", mirrors=["http://m"]),
_spec("c", "3.0", marked),
_spec("d", "4.0", tmp_path / "d"),
]
with patch.object(registry, "install_package") as mock_install:
registry.install_packages(specs, dl)
sequential = [
c for c in mock_install.call_args_list if "extract_progress" not in c[1]
]
batched = [c for c in mock_install.call_args_list if "extract_progress" in c[1]]
assert sorted(c[0][0] for c in sequential) == ["b", "c"]
assert sorted(c[0][0] for c in batched) == ["a", "d"]
def test_install_packages_first_failure_reraised(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Installs are mandatory: the first failure propagates, extras are logged."""
dl = tmp_path / "dl"
dl.mkdir()
(dl / "a-1.0").write_bytes(b"x")
(dl / "b-2.0").write_bytes(b"y")
boom = EsphomeError("bad layout")
def _fail(name: str, *_a, **_kw) -> None:
raise boom if name == "a" else EsphomeError("also bad")
with (
patch.object(registry, "install_package", side_effect=_fail),
pytest.raises(EsphomeError),
):
registry.install_packages(
[_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl
)
# Every failure is named, including the re-raised one: its exception
# message may not identify the package
assert "Could not install a" in caplog.text
assert "Could not install b" in caplog.text
@contextmanager
def _batched_install(tmp_path: Path, extract_progress, prefill_archive: bool = True):
"""Run a batched install_package of pkg@1.0.0; yields the download mock."""
dest = tmp_path / "pkg"
if prefill_archive:
(tmp_path / "dl").mkdir()
(tmp_path / "dl" / "pkg-1.0.0").write_bytes(b"x")
with (
patch.object(registry, "download_with_resume") as mock_download,
patch.object(registry, "archive_extract_all") as mock_extract,
patch.object(
registry,
"registry_download",
return_value=("http://x/pkg.tar.gz", "abc123", 42),
),
):
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
parents=True
)
registry.install_package(
"pkg",
"1.0.0",
dest,
[],
tmp_path / "dl",
expect=("payload",),
extract_progress=extract_progress,
)
yield mock_download, mock_extract
def test_install_package_extract_progress_suppresses_bars(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A batched install routes extraction fractions to the caller and keeps
both private bars and per-package INFO lines off the shared bar."""
fractions: list[float] = []
with (
caplog.at_level(logging.INFO),
_batched_install(tmp_path, fractions.append) as (mock_download, mock_extract),
):
pass
assert mock_extract.call_args[1]["progress"] == fractions.append
# The download tracker reports zero bytes, keeping the shared bar honest
download_progress = mock_download.call_args[1]["progress"]
assert callable(download_progress)
download_progress(42)
assert fractions == [0.0]
assert "Downloading pkg" not in caplog.text
assert "Extracting pkg" not in caplog.text
def test_install_package_batched_missing_archive_keeps_info_log(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A batched archive that unexpectedly needs a real download keeps the
INFO line; the shared bar shows no progress for it."""
with (
caplog.at_level(logging.INFO),
_batched_install(tmp_path, lambda _frac: None, prefill_archive=False),
):
pass
assert "Downloading pkg 1.0.0" in caplog.text
def test_install_packages_dedupes_duplicate_specs(tmp_path: Path) -> None:
"""Duplicate (name, version) entries share one archive and would race
each other; the duplicate takes the sequential path."""
dl = tmp_path / "dl"
dl.mkdir()
(dl / "a-1.0").write_bytes(b"x")
(dl / "b-2.0").write_bytes(b"y")
specs = [
_spec("a", "1.0", tmp_path / "a"),
_spec("a", "1.0", tmp_path / "a2"),
_spec("b", "2.0", tmp_path / "b"),
]
with patch.object(registry, "install_package") as mock_install:
registry.install_packages(specs, dl)
sequential = [
c for c in mock_install.call_args_list if "extract_progress" not in c[1]
]
batched = [c for c in mock_install.call_args_list if "extract_progress" in c[1]]
assert [(c[0][0], c[0][2]) for c in sequential] == [("a", tmp_path / "a2")]
assert sorted(c[0][0] for c in batched) == ["a", "b"]
# The duplicate runs after the batch, which unlinks their shared archive
assert mock_install.call_args_list[-1] == sequential[0]
def test_install_packages_caps_workers(tmp_path: Path) -> None:
"""A high core count is capped; the workers share one disk."""
dl = tmp_path / "dl"
dl.mkdir()
specs = []
for i in range(12):
(dl / f"p{i}-1.0").write_bytes(b"x")
specs.append(_spec(f"p{i}", "1.0", tmp_path / f"p{i}"))
with (
patch.object(registry, "get_usable_cpu_count", return_value=64),
patch.object(registry, "run_batch_downloads", return_value=[]) as batch,
patch.object(registry, "install_package"),
):
registry.install_packages(specs, dl)
assert batch.call_args.kwargs["max_workers"] == 10
def test_install_package_batched_refetch_announced_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A batched archive that fails verification and refetches is announced;
a verify no-op (full size credited immediately) stays silent."""
with (
caplog.at_level(logging.INFO),
_batched_install(tmp_path, lambda _frac: None) as (mock_download, _),
):
progress = mock_download.call_args[1]["progress"]
progress(42)
assert "Re-downloading pkg 1.0.0" not in caplog.text
progress(10)
progress(20)
assert caplog.text.count("Re-downloading pkg 1.0.0") == 1
def test_install_package_batched_refetch_announced_without_size(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A size-less registry entry still announces its refetch on the first
streaming tick."""
with (
caplog.at_level(logging.INFO),
patch.object(registry, "download_with_resume") as mock_download,
patch.object(registry, "archive_extract_all") as mock_extract,
patch.object(
registry,
"registry_download",
return_value=("http://x/pkg.tar.gz", "abc123", None),
),
):
dest = tmp_path / "pkg"
(tmp_path / "dl").mkdir()
(tmp_path / "dl" / "pkg-1.0.0").write_bytes(b"x")
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
parents=True
)
registry.install_package(
"pkg",
"1.0.0",
dest,
[],
tmp_path / "dl",
expect=("payload",),
extract_progress=lambda _frac: None,
)
progress = mock_download.call_args[1]["progress"]
# A verify no-op credits the whole (nonempty) file in one tick
progress(1)
assert "Re-downloading pkg 1.0.0" not in caplog.text
progress(0)
assert "Re-downloading pkg 1.0.0" in caplog.text