mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
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.
This commit is contained in:
@@ -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 (<name>.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:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(":?", "")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
-4
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user