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 f99b95e9a2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-09-03 23:48:53 +02:00
J. Nick Koston 2b7f5f0ea9 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-09-03 23:48:52 +02:00
J. Nick Koston f5859f020b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-09-03 23:48:52 +02:00
J. Nick Koston 5184cc1ace Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-09-03 23:48:52 +02:00
J. Nick Koston a59ab05bc8 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-09-03 23:48:38 +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 8639526e5f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-09-02 11:18:08 +02:00
J. Nick Koston 95fd6c42f3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-09-02 11:18:08 +02:00
J. Nick Koston 42761a66b4 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-09-02 11:18:07 +02:00
J. Nick Koston bbd66c61da Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-09-02 11:18:07 +02:00
J. Nick Koston a5a3163b5d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	tests/script/test_determine_jobs.py
2026-09-02 11:17:59 +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 2a41f7e2a8 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-30 14:57:40 -05:00
J. Nick Koston 9ef69d1576 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-30 14:57:40 -05:00
J. Nick Koston 4a7bd2d2e9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-30 14:57:40 -05:00
J. Nick Koston 60a2bdb935 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-30 14:57:39 -05:00
J. Nick Koston 2a860769ce Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-30 14:57:39 -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 6dc37ffe86 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:52:39 -05:00
J. Nick Koston f528758c8a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:52:39 -05:00
J. Nick Koston d1704cabb2 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:52:39 -05:00
J. Nick Koston de3e4430d3 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-28 20:52:38 -05:00
J. Nick Koston b1d359c4a9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-28 20:52:38 -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 741e436d2c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:45:04 -05:00
J. Nick Koston 34c54e4dce Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:45:03 -05:00
J. Nick Koston 0d75e3e028 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:45:03 -05:00
J. Nick Koston d0a66d26fd Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-28 20:45:03 -05:00
J. Nick Koston 22f4adbb45 Keep espidf-only infra out of the esp8266 native trigger set
The dev merge moved the ESP-IDF trigger constants into clang_tidy_hash;
the esp8266 set keeps its explicit shared-module list with a subset
tripwire against the imported set instead of unioning it wholesale.
2026-08-28 20:44:53 -05:00
J. Nick Koston f2ee227070 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	script/determine-jobs.py
2026-08-28 20:44:18 -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 4b43c026f0 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 20:41:32 -05:00
J. Nick Koston 0f6309bc92 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 20:41:32 -05:00
J. Nick Koston d1b28124bc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 20:41:31 -05:00
J. Nick Koston a817a01097 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-28 20:41:31 -05:00
J. Nick Koston de60f9730e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-28 20:41:31 -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 455b14e10f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 13:52:01 -05:00
J. Nick Koston 9e995377d5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 13:52:01 -05:00
J. Nick Koston ade078a7f6 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 13:52:00 -05:00
J. Nick Koston 5f273cef7f Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-28 13:52:00 -05:00
J. Nick Koston 9f3842a0e0 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-28 13:52:00 -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 7a947cd4c9 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-28 13:07:06 -05:00
J. Nick Koston 40f548a529 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-28 13:07:06 -05:00
J. Nick Koston 7e20e497bd Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-28 13:07:05 -05:00
J. Nick Koston 4137511f81 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-28 13:07:05 -05:00
J. Nick Koston eb1a3bc9e4 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-28 13:07:05 -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 9794eb9fc2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 23:01:18 -05:00
J. Nick Koston fc41843cd5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 23:01:18 -05:00
J. Nick Koston 0d50b57414 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 23:01:18 -05:00
J. Nick Koston 880ffc609d Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-27 23:01:18 -05:00
J. Nick Koston 979aac1cae Silence the framework elf2bin script's SyntaxWarning
The bundled elf2bin.py uses unescaped regex strings that Python 3.12+
flags at compile time; the frozen 3.1.2 framework will never fix it,
so the ninja rule suppresses that category for the one invocation.
2026-08-27 23:01:15 -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 80b592fa16 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 15:52:59 -05:00
J. Nick Koston 665f1d46ca Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 15:52:59 -05:00
J. Nick Koston 5f97c5756b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 15:52:59 -05:00
J. Nick Koston 95ea16b8c4 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-27 15:52:59 -05:00
J. Nick Koston 31470e7997 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-27 15:52:58 -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 4f9a02f85a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 14:04:02 -05:00
J. Nick Koston 8f1e581bf6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 14:04:02 -05:00
J. Nick Koston 8f8e6e95d4 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 14:04:02 -05:00
J. Nick Koston c817bdd25e Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-27 14:04:02 -05:00
J. Nick Koston 7f77764dbb Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-27 14:04:01 -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 c2eb877a17 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-27 12:38:32 -05:00
J. Nick Koston 72c670f5f1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-27 12:38:31 -05:00
J. Nick Koston ecc9248426 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-27 12:38:31 -05:00
J. Nick Koston 9d3a68cd33 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-27 12:38:31 -05:00
J. Nick Koston 1132ec92e8 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-27 12:38:24 -05:00
J. Nick Koston e56edc4af0 Drop the relative-path branch; compile databases record absolute paths 2026-08-27 12:37:01 -05:00
J. Nick Koston c6b1a41e76 Match the pch source TU by resolved path so symlinked build dirs keep the pch 2026-08-27 12:31:53 -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
J. Nick Koston 7449623022 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 22:23:01 -05:00
J. Nick Koston 2eab26052a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 22:22:58 -05:00
J. Nick Koston 96beeed2c2 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 22:22:55 -05:00
J. Nick Koston 85142b5b99 Pass -MF to the probe only alongside a dependency flag 2026-08-26 22:22:42 -05:00
J. Nick Koston 1e13390b6d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 22:06:04 -05:00
J. Nick Koston c916e93988 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 22:06:04 -05:00
J. Nick Koston 3ecd4e3e69 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 22:06:04 -05:00
J. Nick Koston a5afa55122 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch
# Conflicts:
#	esphome/platformio/toolchain.py
2026-08-26 22:06:01 -05:00
J. Nick Koston 3439a2d5ca Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-26 22:05:33 -05:00
J. Nick Koston 3e3655036b Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 14:23:26 -05:00
J. Nick Koston 58d6aec7fd Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 14:23:23 -05:00
J. Nick Koston c97250508a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 14:23:20 -05:00
J. Nick Koston 3dd608cffc Use a drive-qualified basedir in the test on Windows 2026-08-26 14:23:16 -05:00
J. Nick Koston 16e4e05d92 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 14:14:08 -05:00
J. Nick Koston 153bfbc47c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 14:14:05 -05:00
J. Nick Koston 50c9a3a5bb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 14:14:02 -05:00
J. Nick Koston 1d4d28832e Reject relative CCACHE_BASEDIR values too 2026-08-26 14:13:57 -05:00
J. Nick Koston c5d14b9423 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 13:21:30 -05:00
J. Nick Koston 694741cd6b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 13:21:28 -05:00
J. Nick Koston 1719e75e57 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 13:21:25 -05:00
J. Nick Koston 26e51c23b1 Hoist is_joined_include, cover the falsy EXTSUM warning 2026-08-26 13:21:21 -05:00
J. Nick Koston c2d1b1c583 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 13:14:22 -05:00
J. Nick Koston 198e4abc5d Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 13:14:17 -05:00
J. Nick Koston 35ab90fc58 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 13:14:11 -05:00
J. Nick Koston 6530bb62f8 Stat-aware local walk, atomic flag+env application, guard degenerate basedir and -include- flags 2026-08-26 13:14:03 -05:00
J. Nick Koston 77fc8af9f0 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 11:59:22 -05:00
J. Nick Koston b8675a4b56 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 11:59:18 -05:00
J. Nick Koston b497ff17a7 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 11:58:36 -05:00
J. Nick Koston 3730f7bbae Distinguish stat failure from absence, fail closed on unmodelable flags and unreadable local headers 2026-08-26 11:58:20 -05:00
J. Nick Koston f4199a7510 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 10:44:37 -05:00
J. Nick Koston c7f0e17409 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 10:44:35 -05:00
J. Nick Koston 0ba8e3d83e Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 10:44:32 -05:00
J. Nick Koston d0263b0bef Mirror the joined -include fold, harden the probe, fail closed on unreadable closure headers 2026-08-26 10:44:17 -05:00
J. Nick Koston c6d056b240 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 10:03:33 -05:00
J. Nick Koston ca81777eba Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 10:03:31 -05:00
J. Nick Koston 68f2dc3219 Gate the ccache sloppiness on emission for the IDF backend too 2026-08-26 10:03:26 -05:00
J. Nick Koston c7fb26a6d1 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 10:01:27 -05:00
J. Nick Koston 410a3868ea Gate the ccache sloppiness on the pch actually being emitted 2026-08-26 10:00:59 -05:00
J. Nick Koston f665ecf977 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:58:06 -05:00
J. Nick Koston 5e95306bcc Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:58:02 -05:00
J. Nick Koston c813fe8e35 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:57:59 -05:00
J. Nick Koston a69f2c8f66 Fold the joined src force-include spelling, export ccache settings after the flags land 2026-08-26 09:57:44 -05:00
J. Nick Koston c025b875d1 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:34:43 -05:00
J. Nick Koston 6a5a092f03 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:34:41 -05:00
J. Nick Koston c10fb88430 Trim comments to repo standard 2026-08-26 09:34:37 -05:00
J. Nick Koston 5be8ad98e8 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:33:15 -05:00
J. Nick Koston c9b4086a92 Trim comments and docstrings to repo standard 2026-08-26 09:33:06 -05:00
J. Nick Koston 639e1c2fdf Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 09:08:08 -05:00
J. Nick Koston 6722ec22ab Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 09:08:05 -05:00
J. Nick Koston 3d35a111ac Pin the compile locale in the generic pch flow too 2026-08-26 09:08:02 -05:00
J. Nick Koston a07bdf3a03 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 09:07:56 -05:00
J. Nick Koston 397ae10c55 Demote invalid-pch on PlatformIO too, pin the compile locale, document the .sum invariant 2026-08-26 09:07:38 -05:00
J. Nick Koston e2446cad8f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 08:40:41 -05:00
J. Nick Koston 404cf7b728 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 08:40:38 -05:00
J. Nick Koston 987da8f5c4 Treat resource exhaustion as transient in the generic pch flow too 2026-08-26 08:40:35 -05:00
J. Nick Koston c9963881ba Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 08:39:47 -05:00
J. Nick Koston 7417dcdd7b Demote invalid-pch under user -Werror, catch the joined -include spelling, treat resource exhaustion as transient 2026-08-26 08:39:27 -05:00
J. Nick Koston 2a7245ac79 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 00:46:51 -05:00
J. Nick Koston 1ca8b7066c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 00:46:48 -05:00
J. Nick Koston e13aa8b775 Read the pch sidecars defensively in the generic flow too 2026-08-26 00:46:45 -05:00
J. Nick Koston 3a483e3284 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 00:46:31 -05:00
J. Nick Koston f5593deb38 Warn and skip the pch when a user -include precedes it, walk angle includes, read sidecars defensively 2026-08-26 00:46:14 -05:00
J. Nick Koston 0fea10f263 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-26 00:06:44 -05:00
J. Nick Koston f8f12b3a5d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-26 00:06:43 -05:00
J. Nick Koston 187199e9b3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-26 00:06:43 -05:00
J. Nick Koston fb5a042dfe Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-26 00:06:43 -05:00
J. Nick Koston 85dd29f7e0 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-26 00:06:43 -05:00
J. Nick Koston a892f47f89 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:42:31 -05:00
J. Nick Koston 9fa031f134 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:42:31 -05:00
J. Nick Koston c9044bb860 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 23:42:30 -05:00
J. Nick Koston b255e77e76 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:42:30 -05:00
J. Nick Koston c2767c096c Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 23:42:30 -05:00
J. Nick Koston bffbc6dce5 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:16:53 -05:00
J. Nick Koston ba9131324a Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:16:53 -05:00
J. Nick Koston 5ab40084c9 Merge remote-tracking branch 'origin/platformio-pch-libretiny' into platformio-pch-libretiny 2026-08-25 23:16:53 -05:00
J. Nick Koston 82b9575b6f Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:16:52 -05:00
J. Nick Koston 55f1122eba Merge remote-tracking branch 'origin/esp8266-native-pch' into esp8266-native-pch 2026-08-25 23:16:52 -05:00
J. Nick Koston 6223ecda32 Merge remote-tracking branch 'origin/platformio-pch-rp2' into platformio-pch-rp2 2026-08-25 23:16:52 -05:00
J. Nick Koston 5bd7884ac1 Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 23:16:52 -05:00
J. Nick Koston c1c78ebf18 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:16:33 -05:00
J. Nick Koston dd05cdd2f8 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:16:33 -05:00
J. Nick Koston cfcbf5b420 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:16:33 -05:00
J. Nick Koston fb640bdc47 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 23:16:32 -05:00
J. Nick Koston 20e5b23b86 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 23:16:32 -05:00
J. Nick Koston 80206fd232 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 23:03:16 -05:00
J. Nick Koston e18bcc74f3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 23:03:13 -05:00
J. Nick Koston 40f74815f0 Widen the identity-unknown guard to UnicodeError in the generic prepare_pch 2026-08-25 23:03:11 -05:00
J. Nick Koston eb28f2630a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 23:02:56 -05:00
J. Nick Koston 10204c6bd9 Surrogateescape the digest extras, keep absolute force-includes consumer-only 2026-08-25 23:02:43 -05:00
J. Nick Koston 5b53eb9f6c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:55:24 -05:00
J. Nick Koston 0768a1c8cf Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:55:24 -05:00
J. Nick Koston 32c1125116 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:55:24 -05:00
J. Nick Koston 8e5d72a133 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:55:24 -05:00
J. Nick Koston ccaec42798 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:55:23 -05:00
J. Nick Koston 5cd9e77f3d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:53:03 -05:00
J. Nick Koston 5a05184ee3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:53:03 -05:00
J. Nick Koston a9284c7d4b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:53:03 -05:00
J. Nick Koston 5a1f3a382d Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:53:02 -05:00
J. Nick Koston 813008200c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:53:02 -05:00
J. Nick Koston 53a3501bdc Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:40:46 -05:00
J. Nick Koston bcdefa873b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:40:46 -05:00
J. Nick Koston 6c86c02769 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:40:46 -05:00
J. Nick Koston 83eede0d9d Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:40:46 -05:00
J. Nick Koston 32c3bf7f15 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:40:46 -05:00
J. Nick Koston 9304661da3 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:39:45 -05:00
J. Nick Koston fe95bd2d0c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:39:45 -05:00
J. Nick Koston 2a45a21ad3 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:39:45 -05:00
J. Nick Koston df8fb0fb49 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:39:45 -05:00
J. Nick Koston 1895bef123 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:39:44 -05:00
J. Nick Koston 85d49bd940 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:29:40 -05:00
J. Nick Koston 36062184a1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:29:40 -05:00
J. Nick Koston 2864ea8c9a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:29:40 -05:00
J. Nick Koston bf849f441f Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:29:40 -05:00
J. Nick Koston 97bf416a2b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:29:39 -05:00
J. Nick Koston 5f81a04086 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:23:08 -05:00
J. Nick Koston ac176636db Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:23:08 -05:00
J. Nick Koston aebcc71201 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:23:07 -05:00
J. Nick Koston fc654c4c63 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:23:07 -05:00
J. Nick Koston 204cec24a1 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:23:07 -05:00
J. Nick Koston 68ba1b9a7f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 22:04:06 -05:00
J. Nick Koston 63e6f9bf70 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 22:04:06 -05:00
J. Nick Koston daccadca3f Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 22:04:06 -05:00
J. Nick Koston 3e9b6b0355 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 22:04:06 -05:00
J. Nick Koston 5b2670ea4f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 22:04:05 -05:00
J. Nick Koston f4a5671445 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 21:45:12 -05:00
J. Nick Koston 2beabb2ca3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 21:45:09 -05:00
J. Nick Koston d2c2414425 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 21:45:06 -05:00
J. Nick Koston 5a8e6761cb Fold only src-resolvable force-includes, surrogateescape the digest names, harden flag lexing and failure reporting 2026-08-25 21:44:52 -05:00
J. Nick Koston c6f21e2447 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 21:33:57 -05:00
J. Nick Koston 06556ceac5 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 21:33:56 -05:00
J. Nick Koston 9be7585000 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 21:33:56 -05:00
J. Nick Koston b253f32788 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 21:33:56 -05:00
J. Nick Koston 9db1835c40 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 21:33:56 -05:00
J. Nick Koston 80c9ccd657 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:56:19 -05:00
J. Nick Koston c405d94f45 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:56:16 -05:00
J. Nick Koston f4003969ba Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:56:13 -05:00
J. Nick Koston 73cc3af130 Union sloppiness tokens on the PlatformIO path too, survive non-UTF-8 include names 2026-08-25 20:56:00 -05:00
J. Nick Koston 0194e2349c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:48:39 -05:00
J. Nick Koston cf2ddde04c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:48:37 -05:00
J. Nick Koston 365dcba506 Skip compile-DB entries with null fields instead of raising 2026-08-25 20:48:28 -05:00
J. Nick Koston 565ffad6f4 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:43:51 -05:00
J. Nick Koston 2145f50bb5 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:43:48 -05:00
J. Nick Koston a7e4cf536b Drop duplicate ccache test, keep full compiler output at debug, guard empty compile-DB directory 2026-08-25 20:43:37 -05:00
J. Nick Koston 62f8451831 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:40:38 -05:00
J. Nick Koston 120ac5e516 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:40:35 -05:00
J. Nick Koston eaa63e2695 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:40:32 -05:00
J. Nick Koston 6aa5540ff8 Cite esphome#8693 for the intended Arduino.h visibility 2026-08-25 20:40:20 -05:00
J. Nick Koston 259824d13d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:26:29 -05:00
J. Nick Koston daf273b650 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:26:29 -05:00
J. Nick Koston faa93cc6e9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 20:26:29 -05:00
J. Nick Koston 2e7aa4d534 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 20:26:28 -05:00
J. Nick Koston b5dd3d00b8 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 20:26:28 -05:00
J. Nick Koston 90c804886d Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 20:00:39 -05:00
J. Nick Koston e6e738775b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 20:00:37 -05:00
J. Nick Koston 791020f778 Merge follow-through: discard the pch when its identity cannot be established 2026-08-25 20:00:27 -05:00
J. Nick Koston 0c5cad19dc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 19:59:34 -05:00
J. Nick Koston c419fc4bbc Skip the pch when a header identity is unknown, union missing sloppiness tokens, report projenv import failures 2026-08-25 19:59:25 -05:00
J. Nick Koston c15bafacfb Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 19:24:00 -05:00
J. Nick Koston 7bd2dd944c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 19:23:58 -05:00
J. Nick Koston 84028e787a Log the pch line only when true, fail closed on unreadable sdkconfig, treat signal-killed compiles as transient 2026-08-25 19:23:49 -05:00
J. Nick Koston 5d745f0605 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 19:08:39 -05:00
J. Nick Koston 5672302b01 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 19:08:37 -05:00
J. Nick Koston 9faddeb0bc Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 19:08:34 -05:00
J. Nick Koston 9636717b32 Warn on unresolvable force-includes and pch_defines-less sloppiness, vary the unreadable marker 2026-08-25 19:08:21 -05:00
J. Nick Koston e25ffaf017 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:55:03 -05:00
J. Nick Koston 80b62ceacc Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:55:00 -05:00
J. Nick Koston 81bca96e1b Run the gch compile from its resolution dir, self-clean when disabled, log the opt-out hint, pin the knob in tests 2026-08-25 18:54:48 -05:00
J. Nick Koston 256457384c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:20:37 -05:00
J. Nick Koston e5c6720063 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 18:20:36 -05:00
J. Nick Koston 4dc10199b9 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 18:20:36 -05:00
J. Nick Koston d5609f8dc6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:20:36 -05:00
J. Nick Koston a4dd668e69 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:20:36 -05:00
J. Nick Koston d08bb3992a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:19:27 -05:00
J. Nick Koston 8197fcd5a1 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:19:27 -05:00
J. Nick Koston 85520f520e Merge remote-tracking branch 'origin/platformio-pch-libretiny' into platformio-pch-libretiny 2026-08-25 18:19:27 -05:00
J. Nick Koston 2dd850b990 Merge remote-tracking branch 'origin/platformio-pch-rp2' into platformio-pch-rp2 2026-08-25 18:19:27 -05:00
J. Nick Koston aee60fa48a Merge remote-tracking branch 'origin/esp8266-native-pch' into esp8266-native-pch 2026-08-25 18:19:26 -05:00
J. Nick Koston d0bbd40e3b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:19:26 -05:00
J. Nick Koston 7c9e7a807d Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 18:19:26 -05:00
J. Nick Koston b11b60dd86 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:18:24 -05:00
J. Nick Koston efeb608725 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:18:24 -05:00
J. Nick Koston 7cc53d90bd Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 18:18:23 -05:00
J. Nick Koston 7b7acf7656 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 18:18:23 -05:00
J. Nick Koston bbd3e60d5b Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:18:23 -05:00
J. Nick Koston 600dab7185 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:13:33 -05:00
J. Nick Koston 609d9dfab3 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:13:31 -05:00
J. Nick Koston 8118575491 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 18:13:29 -05:00
J. Nick Koston 1b54d1b5b0 Survive -t nobuild, skip library trees in the local include digest, drop the false ENV scoping claim 2026-08-25 18:13:15 -05:00
J. Nick Koston a28086547f Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:05:34 -05:00
J. Nick Koston 0cdd654cc6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:05:31 -05:00
J. Nick Koston 3e2dc30c06 Move the curated header list to build_helpers as PCH_DEFAULT_HEADERS 2026-08-25 18:05:23 -05:00
J. Nick Koston a400753d96 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 18:03:02 -05:00
J. Nick Koston 0f5050a617 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 18:03:00 -05:00
J. Nick Koston 7387211cf1 Move the generic pch build machinery into build_helpers/pch.py 2026-08-25 18:02:51 -05:00
J. Nick Koston 4819f986bc Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 17:58:31 -05:00
J. Nick Koston 904083d531 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 17:58:29 -05:00
J. Nick Koston 2a41892559 Simplify: reuse discard_pch, effective_ccache_basedir, and idedata's C++ suffixes 2026-08-25 17:58:05 -05:00
J. Nick Koston e1eff7447d Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 17:58:05 -05:00
J. Nick Koston b6c1552bcb Simplify: shared artifact names, one cxx override param, extracted gch compile, transient spawn semantics in the script 2026-08-25 17:56:23 -05:00
J. Nick Koston d8255cbece Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 17:48:14 -05:00
J. Nick Koston 6175ff24fe Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 17:48:12 -05:00
J. Nick Koston 1059ecf50c Fold header order into pch checksum, gate rebuild-forcing touch, guard malformed compile DBs 2026-08-25 17:48:01 -05:00
J. Nick Koston c5c7b598c9 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 17:45:02 -05:00
J. Nick Koston 564f3e31c1 Harden idedata -include anchoring, probe exit check, package identity, and failure diagnostics 2026-08-25 17:44:54 -05:00
J. Nick Koston 10a81076b2 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 16:20:46 -05:00
J. Nick Koston 9a2644e6e6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 16:20:43 -05:00
J. Nick Koston 743f1dcb09 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 16:20:06 -05:00
J. Nick Koston a4a8c77151 Resolve idedata force-include paths, test probe rejection and env scoping, derive clean names 2026-08-25 16:19:29 -05:00
J. Nick Koston ceac527259 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:54:05 -05:00
J. Nick Koston 0908d1d3c6 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:54:02 -05:00
J. Nick Koston 278abf2771 Treat pch spawn errors as transient, keep user force-includes, fold command into checksum 2026-08-25 15:53:54 -05:00
J. Nick Koston 408dcd353a Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:44:03 -05:00
J. Nick Koston ca9500b5a8 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:44:03 -05:00
J. Nick Koston b25e30c0b7 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:44:02 -05:00
J. Nick Koston c1dfecf964 Cover the partial-clean pch artifact removal 2026-08-25 15:43:18 -05:00
J. Nick Koston cce6f71076 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:38:05 -05:00
J. Nick Koston 36790f78ab Merge branch 'platformio-pch-libretiny' of https://github.com/esphome/esphome into platformio-pch-libretiny 2026-08-25 15:38:04 -05:00
J. Nick Koston f215e3f45b Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:38:03 -05:00
J. Nick Koston fcaf17dafa Merge branch 'platformio-pch-rp2' of https://github.com/esphome/esphome into platformio-pch-rp2 2026-08-25 15:38:03 -05:00
J. Nick Koston 76ed94fe28 Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 15:38:02 -05:00
J. Nick Koston be60eca212 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:37:16 -05:00
J. Nick Koston b24bfa7552 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:37:16 -05:00
J. Nick Koston 1b325a66ce Warn when a consumer ignores the precompiled header 2026-08-25 15:37:15 -05:00
J. Nick Koston 273e5f22db Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:37:03 -05:00
J. Nick Koston d08c524f19 Harden the pch script for Windows paths and make ignored pchs visible 2026-08-25 15:36:41 -05:00
pre-commit-ci-lite[bot] f198964bc5 apply automatic formatting fixes 2026-08-25 20:32:46 +00:00
pre-commit-ci-lite[bot] 6431f6da35 apply automatic formatting fixes 2026-08-25 20:32:42 +00:00
pre-commit-ci-lite[bot] 4c26f23e76 apply automatic formatting fixes 2026-08-25 20:32:17 +00:00
pre-commit-ci-lite[bot] 18eb05177b apply automatic formatting fixes 2026-08-25 20:30:53 +00:00
J. Nick Koston f438e5a19a Merge branch 'platformio-pch-libretiny' of https://github.com/esphome/esphome into platformio-pch-libretiny 2026-08-25 15:29:59 -05:00
J. Nick Koston 92233e0ff2 Merge branch 'platformio-pch-rp2' of https://github.com/esphome/esphome into platformio-pch-rp2 2026-08-25 15:29:58 -05:00
J. Nick Koston 702da28c9c Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 15:29:58 -05:00
J. Nick Koston 8421fe0170 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:29:16 -05:00
J. Nick Koston 91eb7f2f7c Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:29:16 -05:00
J. Nick Koston 88bcb7ff03 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:29:16 -05:00
J. Nick Koston 009e28998d Merge branch 'esp8266-native-pch' into platformio-pch-libretiny 2026-08-25 15:28:36 -05:00
J. Nick Koston c50a1a068c Merge branch 'esp8266-native-pch' of https://github.com/esphome/esphome into esp8266-native-pch 2026-08-25 15:28:04 -05:00
J. Nick Koston dc45cd4d3f Probe that the toolchain can load the gch before enabling the pch 2026-08-25 15:27:53 -05:00
J. Nick Koston e52af0dfa8 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:17:14 -05:00
J. Nick Koston 250c5bf606 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:17:14 -05:00
J. Nick Koston 649727fcfb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:17:14 -05:00
J. Nick Koston 6c98f2a177 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 15:17:13 -05:00
J. Nick Koston bf573413c4 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 15:17:13 -05:00
pre-commit-ci-lite[bot] f5fd82bc42 apply automatic formatting fixes 2026-08-25 20:08:23 +00:00
J. Nick Koston 958ea20cc4 Use the shared pch registration helper 2026-08-25 15:06:29 -05:00
J. Nick Koston 243b8a871c Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 15:05:58 -05:00
J. Nick Koston 352129e77e Use the shared pch registration helper 2026-08-25 15:05:44 -05:00
J. Nick Koston e0a7347f41 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 15:05:10 -05:00
J. Nick Koston ce37667b1d Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 15:05:08 -05:00
J. Nick Koston fae9eb0dd2 Hash project-local include dirs into the pch checksum and share the registration gate 2026-08-25 15:04:55 -05:00
J. Nick Koston 0007dfab70 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 14:38:16 -05:00
J. Nick Koston 52176c4d59 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 14:38:16 -05:00
J. Nick Koston aa6e3f74fd Add the OBJECT_DEPENDS edge so a rebuilt gch recompiles its consumers 2026-08-25 14:38:04 -05:00
J. Nick Koston e0662b1551 Merge branch 'platformio-pch-rp2' into platformio-pch-libretiny 2026-08-25 14:29:11 -05:00
J. Nick Koston 61009e1162 Merge branch 'esp32-idf-pch' into platformio-pch-rp2 2026-08-25 14:29:10 -05:00
J. Nick Koston 078e918bdb Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 14:29:10 -05:00
J. Nick Koston 6ab93051b7 Address review: order-covering checksum, loud degraded states, script tests 2026-08-25 14:28:57 -05:00
J. Nick Koston d5c044c7b3 [libretiny] Enable the PlatformIO precompiled header 2026-08-25 14:23:14 -05:00
J. Nick Koston 94b02b46f4 [rp2] Enable the PlatformIO precompiled header 2026-08-25 14:23:14 -05:00
J. Nick Koston d081632c53 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 14:21:46 -05:00
J. Nick Koston 04648a8044 Merge branch 'esp32-idf-pch' of https://github.com/esphome/esphome into esp32-idf-pch 2026-08-25 14:18:56 -05:00
J. Nick Koston 21c9dd33e0 Use native separators in the pch test fixture paths 2026-08-25 14:18:46 -05:00
J. Nick Koston 7d91938b79 Reconstruct compiler argv per SCons element for the gch compile 2026-08-25 14:16:04 -05:00
J. Nick Koston ed59080f62 Tokenize SCons flags per element so spaced defines survive 2026-08-25 14:09:22 -05:00
J. Nick Koston d3f6cbe14a Patch the direct ccache probe in the usable-binary test 2026-08-25 14:06:32 -05:00
J. Nick Koston a63c5b3680 Merge branch 'esp8266-native-pch' of https://github.com/esphome/esphome into esp8266-native-pch 2026-08-25 14:04:38 -05:00
J. Nick Koston a6c4c46930 Merge branch 'esp8266-native-pch' into esp32-idf-pch
# Conflicts:
#	esphome/espidf/framework.py
2026-08-25 14:04:35 -05:00
J. Nick Koston 558acf6900 Make the PlatformIO pch script platform-agnostic 2026-08-25 14:04:29 -05:00
J. Nick Koston 32e11a95cb Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 14:04:02 -05:00
J. Nick Koston 1cb86be73d Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 14:04:02 -05:00
J. Nick Koston 337ecfb8f9 Select the pch TU by source path, expand response files, and key the failure marker on the command 2026-08-25 13:58:34 -05:00
J. Nick Koston b08615f92f Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 13:50:13 -05:00
J. Nick Koston 8625e02b8e Cover the remaining pch branches 2026-08-25 13:50:01 -05:00
J. Nick Koston f2204d155a Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:46:35 -05:00
J. Nick Koston f18b182606 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 13:46:34 -05:00
J. Nick Koston 43c389e1fa Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 13:46:34 -05:00
J. Nick Koston d46518d890 Cover the pch compile-command and failure paths 2026-08-25 13:45:43 -05:00
J. Nick Koston f866f878de Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:44:47 -05:00
J. Nick Koston 7f6a007d27 Add direct tests for the shared pch helpers 2026-08-25 13:44:24 -05:00
J. Nick Koston 59fc8dd2bb Merge remote-tracking branch 'origin/esp32-idf-pch' into esp32-idf-pch 2026-08-25 13:39:50 -05:00
J. Nick Koston 149d4d24a1 Merge branch 'esp8266-native-pch' into esp32-idf-pch 2026-08-25 13:39:50 -05:00
J. Nick Koston 0e54f66e78 Merge remote-tracking branch 'origin/esp8266-native-pch' into esp8266-native-pch 2026-08-25 13:39:49 -05:00
J. Nick Koston 35104faaba Merge branch 'esp8266-native-pch' into esp32-idf-pch
# Conflicts:
#	esphome/espidf/framework.py
2026-08-25 13:38:03 -05:00
J. Nick Koston c27f97b8f9 [esp32] Precompile the shared core headers for src compiles 2026-08-25 13:37:42 -05:00
J. Nick Koston de7f622983 Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 13:37:27 -05:00
J. Nick Koston 986c0563b2 Merge remote-tracking branch 'origin/esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 13:36:15 -05:00
J. Nick Koston 6c15897d26 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 13:34:52 -05:00
J. Nick Koston 71cf6049aa [esp32] Precompile the shared core headers for src compiles 2026-08-25 13:33:56 -05:00
J. Nick Koston 2f1d21191e Merge remote-tracking branch 'origin/esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 13:32:52 -05:00
J. Nick Koston 83a2f78075 Walk the include closure in the pch checksum and honor a user CCACHE_BASEDIR 2026-08-25 13:32:17 -05:00
J. Nick Koston e090311ac2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 13:21:58 -05:00
J. Nick Koston aaf76710a9 Merge remote-tracking branch 'origin/esp8266-arduino-toolchain' into esp8266-native-pch 2026-08-25 13:16:51 -05:00
J. Nick Koston 2665a4fa12 [esp8266] Precompile the shared prefix header for src compiles 2026-08-25 13:16:51 -05:00
J. Nick Koston d77be85afa Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 12:49:56 -05:00
J. Nick Koston dccd4e91d2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 12:39:03 -05:00
J. Nick Koston c91fa2b6fc Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 12:37:47 -05:00
J. Nick Koston 422d1c67da Honor bulk in every per-address decode marker 2026-08-25 11:54:33 -05:00
J. Nick Koston 6c14bff747 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-25 10:47:59 -05:00
J. Nick Koston a0dcb6a5f5 Demote bulk stack-word decode misses to debug, count built components per pattern 2026-08-25 08:37:33 -05:00
J. Nick Koston 2ee448a7a8 Cover the failed-probe fall-through 2026-08-24 19:19:30 -05:00
J. Nick Koston fd3654e206 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 18:53:58 -05:00
J. Nick Koston 85c0d86040 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-08-24 18:52:51 -05:00
J. Nick Koston 77de13dab3 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 18:48:02 -05:00
J. Nick Koston 260bfc2358 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 18:47:07 -05:00
J. Nick Koston a5ae1097cc Restore the fast fail before the baseline build when nothing matched 2026-08-24 18:45:40 -05:00
J. Nick Koston 1739bbf3de Name explicit ninja targets instead of policing mtimes, unify the no-tests accounting per pattern, mark stale-ELF decodes, glob the idedata caches 2026-08-24 18:15:58 -05:00
J. Nick Koston f30802757e Stamp the compile-DB freshness, defer the silent-component failure past the summary, mark every undecoded address, honor all-platform fixtures 2026-08-24 17:06:59 -05:00
J. Nick Koston 7e2c83a397 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 16:00:09 -05:00
J. Nick Koston 37d5ba1807 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 15:58:32 -05:00
J. Nick Koston 4fb83364e8 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 14:39:01 -05:00
J. Nick Koston 634dfe6f3c Harden the skip path against stale artifacts, tolerate corrupt linker scripts, guard the baseline degrade, mark undecoded addresses 2026-08-24 13:49:50 -05:00
J. Nick Koston 0556c212b2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 13:36:49 -05:00
J. Nick Koston 6068d7887f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 12:32:55 -05:00
J. Nick Koston e50057f7bb Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-24 12:20:54 -05:00
J. Nick Koston af500d6bec Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 22:25:38 -05:00
J. Nick Koston ac3a6934f6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 22:02:14 -05:00
J. Nick Koston 1780a54524 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 22:00:36 -05:00
J. Nick Koston 1caad91260 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 21:10:49 -05:00
J. Nick Koston 985a784f5e Point analyze-memory at the platformio escape hatch, test the backend-table invariant instead of asserting at import, fail blank component lists under the flag, clean the arduino idedata cache 2026-08-23 20:53:55 -05:00
J. Nick Koston bae1291d73 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 19:43:43 -05:00
J. Nick Koston f973c7bfb7 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 19:38:34 -05:00
J. Nick Koston 9c2a3baaa9 Name the platform in the analyze-memory refusal, surface parser-shaped idedata failures, count builds per component under the no-tests flag 2026-08-23 19:38:12 -05:00
J. Nick Koston b9d40d2b1b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 19:30:47 -05:00
J. Nick Koston 8af64629d7 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:59:47 -05:00
J. Nick Koston 6f775ca9af Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:45:16 -05:00
J. Nick Koston 4c4ccc4444 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:33:24 -05:00
J. Nick Koston d4f75e5468 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:26:13 -05:00
J. Nick Koston 0e54d7d510 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:20:47 -05:00
J. Nick Koston 129f50b712 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 18:15:05 -05:00
J. Nick Koston b893cee6ca Apply simplify pass: shared idedata warning, platform-keyed backend table, one owner for the cache prefix and trigger files 2026-08-23 18:07:46 -05:00
J. Nick Koston aaebc6e007 Cover the skipped-size-summary warning in run_compile 2026-08-23 18:00:34 -05:00
J. Nick Koston f0f6671c83 Pin that the native upload path imports no platform component package 2026-08-23 17:52:27 -05:00
J. Nick Koston ebb6ffa509 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:51:16 -05:00
J. Nick Koston f82d8ec99b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:46:54 -05:00
J. Nick Koston fde0a835b6 Pin board_build.flash_mode unconditionally, dispatch native backends by toolchain, report a skipped size summary 2026-08-23 17:46:23 -05:00
J. Nick Koston d59f6eb46d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:27:07 -05:00
J. Nick Koston 26982767bd Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:18:01 -05:00
J. Nick Koston b3ad8bd15d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:12:40 -05:00
J. Nick Koston 1f44b89815 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 17:08:17 -05:00
J. Nick Koston e2aee2b28e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:54:58 -05:00
J. Nick Koston f1b2258661 Stop the false build_src_flags warning, share the idedata error tuple, make the no-tests guard per platform 2026-08-23 16:48:36 -05:00
J. Nick Koston 3f10a8ca36 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-08-23 16:43:20 -05:00
J. Nick Koston 09d714bbef Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:36:12 -05:00
J. Nick Koston 6f0dffeb37 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:31:26 -05:00
J. Nick Koston 6b39cbb3e0 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:25:37 -05:00
J. Nick Koston f1d270198c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:18:01 -05:00
J. Nick Koston 008555d600 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:12:15 -05:00
J. Nick Koston ccf7da5414 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:10:02 -05:00
J. Nick Koston 78468d7172 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 16:07:02 -05:00
J. Nick Koston 38c5e4f997 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:59:15 -05:00
J. Nick Koston 1b28c3eadb Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:52:45 -05:00
J. Nick Koston 1cdaddad4f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:46:01 -05:00
J. Nick Koston de4e395715 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:33:44 -05:00
J. Nick Koston 5c9d73c1e2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:27:53 -05:00
J. Nick Koston 63d061bc1d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:09:19 -05:00
J. Nick Koston 21a3509634 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 15:06:58 -05:00
J. Nick Koston d0e27bd174 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 14:42:33 -05:00
J. Nick Koston f0ed1ac8ba Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 14:31:41 -05:00
J. Nick Koston 6ae81d49b9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 14:17:42 -05:00
J. Nick Koston a429721472 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 14:06:14 -05:00
J. Nick Koston b0b52bec81 Ungate the build_src_flags producer for the native generator
The generator now reads build_src_flags instead of hardcoding the
throw_stubs force-include, so the option must be set on both toolchains;
gated, the native build silently lost the include (+168 bytes of
libstdc++ throw strings). Verified byte-identical again.
2026-08-23 14:01:34 -05:00
J. Nick Koston 9e623309b4 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:57:24 -05:00
J. Nick Koston 350a2a84cc Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-08-23 13:56:47 -05:00
J. Nick Koston 51384f9743 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:37:27 -05:00
J. Nick Koston 79fae3b709 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:19:31 -05:00
J. Nick Koston b2d0af3316 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:18:31 -05:00
J. Nick Koston c92b734bb9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:17:28 -05:00
J. Nick Koston 7af97902d2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:12:53 -05:00
J. Nick Koston 36c5e39371 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 13:10:39 -05:00
J. Nick Koston 9c9ddea00c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 12:34:10 -05:00
J. Nick Koston 8d0eab9111 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 12:19:24 -05:00
J. Nick Koston 1b255b429f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 12:05:21 -05:00
J. Nick Koston cf7c6151e7 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 11:37:10 -05:00
J. Nick Koston 412bc4be7f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 10:48:54 -05:00
J. Nick Koston af08a45afe Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 10:37:31 -05:00
J. Nick Koston caf758eaed Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-23 10:33:03 -05:00
J. Nick Koston 33aac54c8f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp32/__init__.py
2026-08-23 10:17:28 -05:00
J. Nick Koston 2a2710d752 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:41:58 -05:00
J. Nick Koston c7772c384e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:23:43 -05:00
J. Nick Koston 005aedd3a6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:21:08 -05:00
J. Nick Koston 3624ff6b19 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:10:58 -05:00
J. Nick Koston 505f4dee03 Mirror the damaged-file recovery tests into the chain 2026-08-22 23:10:37 -05:00
J. Nick Koston ade226456e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:02:22 -05:00
J. Nick Koston f326f7b527 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 23:00:19 -05:00
J. Nick Koston b302f4cb9a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:58:49 -05:00
J. Nick Koston 1543709ddd Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:40:59 -05:00
J. Nick Koston a424629ab6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:39:49 -05:00
J. Nick Koston d1299430bb Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:32:38 -05:00
J. Nick Koston 538942153d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-08-22 22:30:05 -05:00
J. Nick Koston 013a27bee9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:17:28 -05:00
J. Nick Koston dce5185cef Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 22:11:45 -05:00
J. Nick Koston 9fe10af350 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:55:44 -05:00
J. Nick Koston 2fe3eaf97d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:50:50 -05:00
J. Nick Koston 24ea73a626 Gate the zero-test guard behind a flag, keep the upload path import-free, verify all artifacts, regenerate a stale compile DB 2026-08-22 21:47:03 -05:00
J. Nick Koston 9d4df93676 Assert the launcher string verbatim; Path normalization diverged on Windows 2026-08-22 21:42:53 -05:00
J. Nick Koston 390ca116bc Name the idedata consumers in the arduino backend's warning too 2026-08-22 21:42:15 -05:00
J. Nick Koston fbe5a02d6f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:42:14 -05:00
J. Nick Koston beceab6327 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:30:41 -05:00
J. Nick Koston a003fb848a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:24:08 -05:00
J. Nick Koston d43770b6d4 Drop a stale ccache_path patch from the write_project test 2026-08-22 21:22:41 -05:00
J. Nick Koston cc73c0c67e Dispatch esptool flashing via the backend hook, share the native trigger files, gate the probe, cache the closure 2026-08-22 21:21:25 -05:00
J. Nick Koston ff84e53ff1 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:19:00 -05:00
J. Nick Koston aa63eb9734 Move the resolve-for-me ccache sentinel to its one consumer 2026-08-22 21:18:48 -05:00
J. Nick Koston 9144ae462d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 21:17:55 -05:00
J. Nick Koston 64d0137ab8 Verify build artifacts and surface probe diagnostics; fail on a zero-test CI run 2026-08-22 20:49:02 -05:00
J. Nick Koston 13ae59378d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 20:26:38 -05:00
J. Nick Koston 378d5f53e3 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 20:13:48 -05:00
J. Nick Koston eeb26a375f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 20:01:32 -05:00
J. Nick Koston 19bb36c7ff Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 19:55:34 -05:00
J. Nick Koston a80db169a4 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 19:46:47 -05:00
J. Nick Koston cf5fd1f36a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 19:32:13 -05:00
J. Nick Koston 4aafc96ef7 Pin the analyze-memory ELF guard in tests 2026-08-22 19:18:13 -05:00
J. Nick Koston 2af06e3b53 Use the exported consumed-options set, guard the analyze-memory ELF, trigger on the cache action 2026-08-22 19:16:50 -05:00
J. Nick Koston c26f77fe62 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 19:15:09 -05:00
J. Nick Koston b5219d3c60 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 17:24:38 -05:00
J. Nick Koston 7e8b033860 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 17:10:24 -05:00
J. Nick Koston aaf27d0e90 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 16:59:09 -05:00
J. Nick Koston b5fbb767c2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 16:41:54 -05:00
J. Nick Koston e3d731b1df Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 16:38:32 -05:00
J. Nick Koston 53fb66616c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 16:16:23 -05:00
J. Nick Koston 3219f02e23 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/__main__.py
2026-08-22 16:10:47 -05:00
J. Nick Koston 5681890f74 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:58:12 -05:00
J. Nick Koston 5ae3009791 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:51:43 -05:00
J. Nick Koston a97479105f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:47:13 -05:00
J. Nick Koston 3635c05fa5 Non-destructive platform_version check, honest compdb errors, missing-binutils refusal
The platform_version pop defaults to the schema spec so a second
validation pass over an already-validated dict cannot warn about a key
the user never set. An unparsable compile database now fails naming the
parse error and the offending output instead of blaming renamed ninja
rules. analyze-memory validates the native objdump/readelf exist and
fails by tool name rather than silently analyzing with host binutils.
The shared smoke-test helper is renamed _toolchain_components_to_test
(it serves the esp32 PlatformIO job too), and the decode-dedup state is
cleared by an autouse fixture instead of by hand.
2026-08-22 15:36:49 -05:00
J. Nick Koston 160299a41e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:33:41 -05:00
J. Nick Koston 3641621b0e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:25:48 -05:00
J. Nick Koston e8dea9b07f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:10:17 -05:00
J. Nick Koston e00229d22c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:06:32 -05:00
J. Nick Koston ae38e587fa Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 15:03:22 -05:00
J. Nick Koston 298c78ff09 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:54:58 -05:00
J. Nick Koston 2c6cbdef4d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:52:41 -05:00
J. Nick Koston 4d9da7284e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:44:18 -05:00
J. Nick Koston 28107bf966 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:41:35 -05:00
J. Nick Koston 23bc88355b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:38:09 -05:00
J. Nick Koston 42134329e3 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:31:40 -05:00
J. Nick Koston 57248d3d6f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:21:11 -05:00
J. Nick Koston 1d88a5a92b Keep no-op rebuilds quiet
A -n dry-run probe answers 'no work to do' without the real ninja spawn,
and running with cwd instead of -C drops the 'Entering directory' banner
on real builds too.
2026-08-22 14:14:37 -05:00
J. Nick Koston 0e1c7cbb7e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 14:12:57 -05:00
J. Nick Koston 7fe793da17 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 13:51:48 -05:00
J. Nick Koston 893ec5d3f5 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 13:16:00 -05:00
J. Nick Koston c8dd9dd483 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 13:10:55 -05:00
J. Nick Koston efe2dc481d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 12:52:53 -05:00
J. Nick Koston 751914c47f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 12:48:21 -05:00
J. Nick Koston e107747643 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 12:26:39 -05:00
J. Nick Koston e41440b16b Tilde-expand the install prefix before the CI cache-path assertion
The job env carries a literal ~ that Python's expanduser and
actions/cache both resolve; the assertion compared the raw string and
failed the native toolchain job.
2026-08-22 12:23:24 -05:00
J. Nick Koston 19d8f832b9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	esphome/components/esp8266/__init__.py
2026-08-22 12:16:27 -05:00
J. Nick Koston 8b7d199ff9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 12:08:57 -05:00
J. Nick Koston b002a37987 Fail fast on empty compile databases and unsupported analyze-memory toolchains; degrade a missing size tool
ninja -t compdb exits 0 with an empty list when the rule names drift, so
the compile database is now parsed and an empty result fails the build
and drops the stale file. analyze-memory refuses an unsupported
toolchain before paying for the compile. A missing size binary warns
instead of discarding an already-linked build, _parse_app_size returns
explicitly from its not-found branch, and the CI cache action asserts
the caller's install prefix matches the cached path. The esp8266
run_compile dispatch hook and copy_files early-return gain direct
tests.
2026-08-22 12:06:38 -05:00
J. Nick Koston 583f47034f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 12:02:36 -05:00
J. Nick Koston b597a4f02f Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 11:55:59 -05:00
J. Nick Koston fcc7bb8c44 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 11:49:37 -05:00
J. Nick Koston 4866ef97f0 Trim comment essays and hoist function-local test imports 2026-08-22 11:46:49 -05:00
J. Nick Koston 7e675233c6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 11:44:16 -05:00
J. Nick Koston 9304c74de5 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 11:24:58 -05:00
J. Nick Koston 266f67ef13 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 11:04:48 -05:00
J. Nick Koston 772fb65675 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 10:03:46 -05:00
J. Nick Koston 00549813ab Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 09:41:49 -05:00
J. Nick Koston ccce5e9ab2 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 09:30:02 -05:00
J. Nick Koston 685409cf83 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 09:21:11 -05:00
J. Nick Koston 7c91407835 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 00:48:41 -05:00
J. Nick Koston 76a80bd346 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-22 00:18:46 -05:00
J. Nick Koston 0f885b4037 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 21:27:59 -05:00
J. Nick Koston 8b21fa620d Cover the pre-resolved ccache branch of get_idedata 2026-08-21 21:20:58 -05:00
J. Nick Koston 116b4ce444 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 21:11:22 -05:00
J. Nick Koston eecdf3e68d Drop the components-graph memo; it defeats the tested disk-cache semantics
The in-process cache served a stale graph across mtime and version
changes, which the script test suite pins; the saved re-parse was tens
of milliseconds.
2026-08-21 21:11:11 -05:00
J. Nick Koston c0a605c1b9 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 21:01:48 -05:00
J. Nick Koston 9fe9d8da1b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:56:38 -05:00
J. Nick Koston 8b9f5503df Drop the now-inlined pio_options module 2026-08-21 20:55:58 -05:00
J. Nick Koston 17f718e8c9 Simplify the native dispatch and CI cache wiring
A cache-arduino8266 composite action mirrors cache-esp-idf (one key
resolver, dev-writes/PR-restores) and both jobs pin
ESPHOME_ARDUINO8266_PREFIX so YAML and Python agree on the path by
construction. esp32 provides the native_toolchain_module hook, so the
shared dispatcher never names a backend and the unclaimed-native raise
loses its esp-idf carve-out; the esp8266 decode and run_compile hooks
resolve through the same seam. run_compile resolves ccache once and
threads it to the generator, env, and idedata; the compdb rule names
follow the shared kinds; the pio-options warner inlines into the driver
and derives its consumed set from the core routing constant; the
per-toolchain CI narrowing shares one body; create_components_graph is
memoized per run; the decode rate-limit drops its math sentinel.
2026-08-21 20:55:27 -05:00
J. Nick Koston 16bc83032d Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:55:19 -05:00
J. Nick Koston 814109df47 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:51:57 -05:00
J. Nick Koston 80a4178f05 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:49:32 -05:00
J. Nick Koston 9268f52b9e Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:38:33 -05:00
J. Nick Koston f2a878cb25 Stop warning about the now-honored f_cpu and ldscript overrides 2026-08-21 20:31:12 -05:00
J. Nick Koston c9a40e550c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:30:32 -05:00
J. Nick Koston 6070941771 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 20:22:05 -05:00
J. Nick Koston dc0fddd349 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 19:59:57 -05:00
J. Nick Koston 26ea560139 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 19:24:21 -05:00
J. Nick Koston f3395afae1 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 18:56:03 -05:00
J. Nick Koston f451de062a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 17:45:05 -05:00
J. Nick Koston 88e6471a4b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 17:38:34 -05:00
J. Nick Koston daea0cc9f3 Warn on missing decode tools on both toolchains, fail the CI key resolver loudly
The PlatformIO branch of _decode_pc now routes through the same
rate-limited warning as the native one; raw undecoded crash addresses
with no stated reason were undiagnosable at default log level. The CI
cache key uses the assignment form so errexit catches a resolver
failure instead of echo swallowing it into a degenerate key, with a
non-empty check, and the cache comments stop claiming a ccache store
the seed job never populates (it saves before any compile runs).
2026-08-21 17:26:11 -05:00
J. Nick Koston 978d7fc2e7 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 17:19:09 -05:00
J. Nick Koston 27b80f895b Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 17:04:13 -05:00
J. Nick Koston 3e56619cd6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 16:52:50 -05:00
J. Nick Koston 7a99e65853 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 16:07:41 -05:00
J. Nick Koston 0127b483b0 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 15:34:01 -05:00
J. Nick Koston 98f574c251 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 15:19:11 -05:00
J. Nick Koston dbae5246e6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 14:50:47 -05:00
J. Nick Koston b131f51788 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 14:32:37 -05:00
J. Nick Koston 0097ce892c Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 14:12:34 -05:00
J. Nick Koston 9fbc996d52 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 13:33:19 -05:00
J. Nick Koston c92b3cc49a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 13:13:16 -05:00
J. Nick Koston c3c27dc744 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 13:07:04 -05:00
J. Nick Koston 6974a67626 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 12:57:46 -05:00
J. Nick Koston 4936ec0de6 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 12:28:19 -05:00
J. Nick Koston f12cb4350a Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 12:25:14 -05:00
J. Nick Koston c8722421b1 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 11:14:36 -05:00
J. Nick Koston 9ff0198865 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 09:58:45 -05:00
J. Nick Koston 503e3ca724 Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain 2026-08-21 09:36:01 -05:00
J. Nick Koston e5cfb1bdac Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain
# Conflicts:
#	tests/unit_tests/test_main.py
2026-08-21 01:06:06 -05:00
J. Nick Koston aae5538fd5 Gate uploads on a built firmware, refuse unclaimed native builds, seed the CI toolchain cache from dev
The upload path now checks CORE.firmware_bin exists before invoking
esptool and names the fix (compile first) instead of failing inside
esptool. compile_program raises when a native toolchain resolved but no
platform backend claimed the build rather than falling through to a
mis-configured PlatformIO project. The addr2line failure warning now
includes the exception so the cause is visible without debug logging.

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