From 22fa536fa58dadb1a40cb5cf3d61f7aeac4abe9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 10:31:52 +0100 Subject: [PATCH] Simplify the native toolchain driver Drops the ninja dry-run probe (a second full manifest load and stat sweep per build to suppress one line), the compile-database stamp file (a plain mtime compare says the same), and the ccache sentinel (resolve_ccache_path only spawns when it returns a value, so a plain fallback never re-probes). The crash decoder resolves addr2line and the ELF once per run instead of once per address, which removes the module-level warn-dedup dict along with its test fixture. The size summary uses the toolchain path it was handed rather than re-deriving it, and its two failure branches merge. The framework import moves into the functions that need it: the serial upload and logs fast path resolves this module for artifact paths alone and was paying 16 ms and 29 modules for the package-download stack. test_lazy_imports now guards that module so it cannot come back. analyze-memory's idedata candidate list learns the suffixed native cache name, and warn_if_idedata_missing goes back to being a one-for-one extraction. --- esphome/analyze_memory/toolchain.py | 4 + esphome/arduino8266/toolchain.py | 134 ++++++------------ esphome/build_helpers/idedata.py | 8 +- esphome/components/esp8266/__init__.py | 93 +++++------- esphome/components/esp8266/const.py | 2 + esphome/writer.py | 5 +- .../esp8266/test_toolchain_validation.py | 3 +- .../unit_tests/test_arduino8266_toolchain.py | 105 ++------------ tests/unit_tests/test_lazy_imports.py | 7 +- 9 files changed, 107 insertions(+), 254 deletions(-) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 19041ac8071..b64af5ea5b8 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -68,12 +68,16 @@ def idedata_candidates(build_path: Path) -> list[Path]: The candidate idedata JSON paths, most specific first """ name = build_path.name + data_dir = build_path.parent.parent / "idedata" + # Native backends suffix the cache by toolchain (.arduino.json) + suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else [] return [ # In .pioenvs for test builds build_path / ".pioenvs" / name / "idedata.json", # Both toolchains cache it in the data dir, which holds this build dir: # /idedata/.json next to /build/ build_path.parent.parent / "idedata" / f"{name}.json", + *suffixed, # Regular builds, invoked from the config dir or from anywhere Path.cwd() / ".esphome" / "idedata" / f"{name}.json", Path.home() / ".esphome" / "idedata" / f"{name}.json", diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index c0170ca0ff4..339fb44337f 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -6,9 +6,8 @@ import json import logging from pathlib import Path import subprocess -from typing import Any +from typing import TYPE_CHECKING -from esphome.arduino8266 import framework from esphome.build_helpers.ccache import resolve_ccache_path from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, @@ -17,9 +16,12 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, ) from esphome.core import CORE, EsphomeError -from esphome.helpers import write_file_if_changed +from esphome.helpers import write_file from esphome.types import ConfigType +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + _LOGGER = logging.getLogger(__name__) # ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest) @@ -53,6 +55,11 @@ def get_elf_path() -> Path: def _toolchain_tool(name: str) -> Path: + # Imported here, not at module scope: the serial upload/logs fast path + # resolves this module for its artifact paths alone, and framework + # pulls in the whole package-download stack + from esphome.arduino8266 import framework + return framework.toolchain_tool(framework.get_toolchain_path(), name) @@ -75,32 +82,23 @@ def get_readelf_path() -> Path: def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.arduino8266 import framework 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: the probe is not free and three consumers need it ccache = resolve_ccache_path() - ninja_changed = build_gen.write_project(paths, ccache) + build_gen.write_project(paths, ccache) build_dir = get_build_dir() env = framework.get_build_env(paths.toolchain, ccache) - # Regenerate the compile DB when it is older than build.ninja. - # Freshness rides a stamp: the DB is written via write_file_if_changed - # (mtime feeds get_idedata's cache), so identical content would - # otherwise stay "stale" forever + # The DB is a pure function of build.ninja; regenerate when it is older compdb = build_dir / "compile_commands.json" - compdb_stamp = build_dir / ".compile_commands.stamp" ninja_file = build_dir / "build.ninja" - 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 - ): + if not compdb.is_file() or compdb.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: @@ -112,38 +110,13 @@ def run_compile(config: ConfigType, verbose: bool) -> int: targets = ["firmware.factory.bin", "firmware.ota.bin"] cmd += targets - # The dry-run probe keeps a no-op rebuild quiet; a rewritten manifest - # all but guarantees work, so skip it then. cwd (not -C) drops the - # "Entering directory" banner. - 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 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 + # cwd, not -C: drops ninja's "Entering directory" banner + _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 missing targets; existence covers a rule that # ran but wrote elsewhere @@ -200,12 +173,10 @@ def _write_compile_commands( "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) + write_file(compdb, result.stdout) -def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None: +def _parse_app_size(build_dir: Path, paths: 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 @@ -219,50 +190,43 @@ def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | N # A corrupt script degrades the same way, never aborts the 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 - ) + if not (app_size := segment_length(ld_text, "irom0_0_seg")): + _LOGGER.warning("No usable irom0_0_seg in %s; skipping Flash summary", ld_path) return None return app_size -def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool: +def _print_size_summary(build_dir: Path, paths: InstalledPaths) -> bool: """Print the RAM/Flash lines ``ci_memory_impact_extract.py`` parses; False when skipped.""" + from esphome.arduino8266.framework import toolchain_tool 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())], + [ + str(toolchain_tool(paths.toolchain, "size")), + "-A", + "-d", + str(build_dir / "firmware.elf"), + ], capture_output=True, text=True, - check=False, + check=True, close_fds=False, ) - except OSError as err: + except (OSError, subprocess.CalledProcessError) 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]) + # -d prints decimal sizes; anything else trips the missing-sections guard + sections = { + parts[0]: int(parts[1]) + for line in result.stdout.splitlines() + if (parts := line.split())[:1] and parts[0].startswith(".") and len(parts) >= 2 + if parts[1].isdigit() + } if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections): # A defaulted 0 would print a confidently wrong total for CI's metric _LOGGER.warning( @@ -282,18 +246,14 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> boo 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: +def get_idedata(ccache: str | None = None) -> dict | None: """Derive idedata from the build's compile_commands.json (same contract as ``espidf.toolchain.get_idedata``).""" from esphome.build_helpers.idedata import load_or_build_idedata - if ccache is _CCACHE_UNRESOLVED: - # Uncached: env/PATH can change between builds in a host process - ccache = resolve_ccache_path() + # A disabled ccache resolves to None without spawning anything, so + # re-resolving here costs nothing when the caller has no answer + launcher = ccache or resolve_ccache_path() return load_or_build_idedata( get_build_dir() / "compile_commands.json", get_elf_path(), @@ -302,5 +262,5 @@ def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None: 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, + launcher=str(launcher) if launcher else None, ) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index e76422257af..98ee8044030 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -47,13 +47,7 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: "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) + _LOGGER.debug("Idedata failure detail", exc_info=True) # C++ translation-unit suffixes used to identify ESPHome source files. diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 93547ab6767..d285c19a389 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,7 +3,6 @@ from pathlib import Path import platform import re import subprocess -import time from typing import Any import esphome.codegen as cg @@ -38,7 +37,7 @@ from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script +from .boards import BOARDS, board_ld_script from .const import ( BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, @@ -46,6 +45,7 @@ from .const import ( CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, KEY_BOARD, + KEY_DECODE_TOOLS, KEY_ESP8266, KEY_PIN_INITIAL_STATES, KEY_SCANF_FLOAT, @@ -141,12 +141,7 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType: "'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 - ): + if config[CONF_BOARD] not in BOARDS: raise cv.Invalid( f"Board '{config[CONF_BOARD]}' is not supported by " "'toolchain: arduino'; use 'toolchain: platformio'" @@ -591,73 +586,53 @@ ESP8266_EXCEPTION_CODES = { } -_DECODE_WARNED_AT: dict[str, float] = {} +def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: + """``(addr2line, elf)`` for this build, or None after warning why.""" + 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(): + _LOGGER.warning("Cannot decode crash addresses: %s missing", path) + return None + return str(addr2line), str(elf) + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + if not idedata.addr2line_path or not idedata.firmware_elf_path: + _LOGGER.warning("Cannot decode crash addresses: no addr2line or ELF in idedata") + return None + return idedata.addr2line_path, idedata.firmware_elf_path -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_tools(config: ConfigType) -> tuple[str, str] | None: + """Resolved once per run: the tools are a property of the build, not of + the address, so a stack dump cannot repeat the failure warning.""" + data = CORE.data.setdefault(KEY_ESP8266, {}) + if KEY_DECODE_TOOLS not in data: + data[KEY_DECODE_TOOLS] = _resolve_decode_tools(config) + return data[KEY_DECODE_TOOLS] 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 + # Bulk stack words are ~150 per dump; only named registers warn + log = _LOGGER.debug if bulk else _LOGGER.warning + if (tools := _decode_tools(config)) is None: + log("Not decoded %s (no addr2line or ELF)", addr) + return + addr2line, elf = tools command = [addr2line, "-pfiaC", "-e", elf, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - # Warn (rate-limited across a dump); mark undecoded addresses - # 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) + log("Could not decode crash address %s (%s)", addr, err) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return if "?? ??:0" in translation: - # A named register that fails to decode is confusing silence; a - # bulk stack word failing is the expected common case - log = _LOGGER.debug if bulk else _LOGGER.warning log("Not decoded %s (address not in %s)", addr, elf) return translation = translation.replace(" at ??:?", "").replace(":?", "") diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index ac2fe0aa3b4..bf4dd1a3584 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -19,6 +19,8 @@ KEY_SERIAL1_REQUIRED = "serial1_required" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" +# Crash-decode tools, resolved once per run by __init__ +KEY_DECODE_TOOLS = "decode_tools" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/writer.py b/esphome/writer.py index 6ffa090e189..bddb6a0f5f8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -612,10 +612,7 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): # idedata caches live under the data dir, not the build path; globbed # so a future backend suffix cannot 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"), - ): + for idedata_cache in idedata_dir.glob(f"{CORE.name}*.json"): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index aecc8a13027..4e3cfcbfd08 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -32,10 +32,9 @@ 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 + # The decode-tool cache lives in CORE.data, which reset_core clears CORE.toolchain = Toolchain.ARDUINO - esp8266._DECODE_WARNED_AT.clear() yield - esp8266._DECODE_WARNED_AT.clear() def _config( diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 15f0e9717da..a9c0546b4b1 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +import subprocess from unittest.mock import MagicMock, patch import pytest @@ -93,7 +94,6 @@ 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, @@ -108,48 +108,19 @@ def test_run_compile_success(tmp_path: Path) -> None: {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] + call = next(c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])) + cmd = call[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 {"firmware.factory.bin", "firmware.ota.bin"} <= set(cmd) + assert cmd[cmd.index("-j") + 1] == "4" + # cwd, not -C, so ninja prints no "Entering directory" banner assert "-C" not in cmd - assert ninja_calls[1][1]["cwd"] is not None + assert call[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.""" @@ -178,34 +149,6 @@ def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: 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: @@ -386,7 +329,7 @@ def test_print_size_summary_size_tool_failure( with patch.object( toolchain.subprocess, "run", - return_value=MagicMock(returncode=1, stdout="", stderr="bad elf"), + side_effect=subprocess.CalledProcessError(1, "size", stderr="bad elf"), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" @@ -459,7 +402,7 @@ def test_print_size_summary_unparsable_section( caplog: pytest.LogCaptureFixture, ) -> None: """A totals-relevant section that fails to parse must not produce a - confident wrong number; an irrelevant one only warns.""" + confident wrong number; an irrelevant one is ignored.""" bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc") with patch.object( toolchain.subprocess, @@ -468,7 +411,7 @@ def test_print_size_summary_unparsable_section( ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" - assert "Unparsable size output" in caplog.text + assert "missing section(s) .bss" in caplog.text caplog.clear() harmless = _SIZE_OUTPUT + ".broken abc 0\n" @@ -482,7 +425,7 @@ def test_print_size_summary_unparsable_section( ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert "RAM:" in capsys.readouterr().out - assert "Unparsable size output" in caplog.text + assert not caplog.text def test_print_size_summary_missing_section_skips_summary( @@ -590,32 +533,6 @@ def test_parse_app_size_non_utf8_ld_warns( 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.""" diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 736cd524d2f..1eeddd80eac 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -34,7 +34,12 @@ HEAVY_MODULES = ( # Everything the storage fast path must keep out of sys.modules; the # existence guard and the leak check must watch the same list. -FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ( + "esphome.components.esp32", + # A native backend module resolves artifact paths on this path; its + # framework half pulls in the whole package-download stack + "esphome.platformio.registry", +) # Heavy only for modules that must not know about the API transport; # in the existence guard so a rename can't silently no-op its check.