Stamp the compile-DB freshness, defer the silent-component failure past the summary, mark every undecoded address, honor all-platform fixtures

This commit is contained in:
J. Nick Koston
2026-08-24 17:06:59 -05:00
parent 7e2c83a397
commit f30802757e
6 changed files with 39 additions and 17 deletions
+7 -1
View File
@@ -97,13 +97,19 @@ def run_compile(config: ConfigType, verbose: bool) -> int:
# (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 compdb.stat().st_mtime < ninja_file.stat().st_mtime
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:
+2 -2
View File
@@ -22,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 = (
@@ -57,8 +59,6 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
_LOGGER.warning("Idedata failure detail", exc_info=True)
_LOGGER = logging.getLogger(__name__)
# C++ translation-unit suffixes used to identify ESPHome source files.
_CXX_SUFFIXES = (".cpp", ".cc")
# Suffixes of input/output files that appear bare on the command line (and so
+13 -6
View File
@@ -627,15 +627,17 @@ ESP8266_EXCEPTION_CODES = {
_DECODE_WARNED_AT: dict[str, float] = {}
def _warn_decode_problem(key: str, message: str, *args) -> None:
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."""
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
return False
_DECODE_WARNED_AT[key] = now
_LOGGER.warning(message, *args)
return True
def _decode_pc(config: ConfigType, addr: str) -> None:
@@ -647,6 +649,8 @@ def _decode_pc(config: ConfigType, addr: str) -> None:
_warn_decode_problem(
str(path), "Cannot decode crash addresses: %s missing", path
)
# The detailed warning names no address, so mark each one
_LOGGER.warning("Not decoded %s (toolchain file missing)", addr)
return
addr2line, elf = str(addr2line), str(elf)
else:
@@ -658,6 +662,7 @@ def _decode_pc(config: ConfigType, addr: str) -> None:
"no-addr2line",
"Cannot decode crash addresses: no addr2line or ELF in idedata",
)
_LOGGER.warning("Not decoded %s (no addr2line or ELF)", addr)
return
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
command = [addr2line, "-pfiaC", "-e", elf, addr]
@@ -667,10 +672,12 @@ def _decode_pc(config: ConfigType, addr: str) -> None:
# 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
_warn_decode_problem(
if not _warn_decode_problem(
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
)
_LOGGER.warning("Not decoded %s (addr2line failed)", addr)
):
# The detailed warning already named this address; mark only
# the addresses whose warning was rate-limited away
_LOGGER.warning("Not decoded %s (addr2line failed)", addr)
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return
+3
View File
@@ -692,6 +692,9 @@ def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
# 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
)
+12 -7
View File
@@ -1086,8 +1086,11 @@ def test_components(
# check is per platform: a component whose fixture exists only for other
# platforms contributes nothing to this leg.
def _has_platform_test(component: str) -> bool:
# "all" fixtures build on every platform (mirrors the run loop)
return any(
not platform_filter or test.stem.split(".")[-1].startswith(platform_filter)
not platform_filter
or (suffix := test.stem.split(".")[-1]) == "all"
or suffix.startswith(platform_filter)
for test in all_tests.get(component, [])
)
@@ -1214,19 +1217,21 @@ def test_components(
toolchain=toolchain,
)
silent: list[str] = []
if fail_on_no_tests:
# A green run that built nothing for a requested component (renamed
# fixture, missing base file, version-suffix mismatch) must not pass
# CI. Per component: an all-or-nothing check would let one silent
# component hide behind the others. Opt-in: some legs (the esp32-ard
# smoke subset) legitimately match nothing.
# 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}
if silent := [
silent = [
p for p in component_patterns if p and "*" not in p and p not in built
]:
]
if silent:
print(f"No tests ran for requested component(s): {', '.join(silent)}")
return 1
if not test_results:
elif not test_results:
print("No tests matched the requested components/platform")
return 1
@@ -1261,7 +1266,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
+2 -1
View File
@@ -7125,7 +7125,8 @@ def test_upload_using_esptool_arduino_toolchain(
mock_run_external_command_main: Mock,
) -> None:
"""The native ESP8266 Arduino toolchain flashes its factory image at
0x0, dispatched through the platform's native_toolchain_module hook."""
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