Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain

This commit is contained in:
J. Nick Koston
2026-08-20 23:58:42 -05:00
11 changed files with 368 additions and 54 deletions
+31 -12
View File
@@ -8,10 +8,11 @@ resolution/download pipeline in ``esphome.platformio.library``. Nothing here
is core-specific: the caller names the PlatformIO platform, MCU, and cache
key of the Arduino core it builds.
Known deviation: flat-layout (``library.properties``, no ``src/``) libraries
get the recursive default source filter rather than PlatformIO's root-only
Arduino-1.0 filter; no bundled library is affected, only user-supplied ones
carrying sources in unusual subdirectories.
Known deviations: flat-layout (``library.properties``, no ``src/``)
libraries get the recursive default source filter rather than PlatformIO's
root-only Arduino-1.0 filter (no bundled library is affected), and the
Arduino ``dot_a_linkage`` property is honored even though PlatformIO
ignores it.
Mirrors PlatformIO's ``lib_ldf_mode=off`` behavior: each library builds into
its own static archive and every library's include dir joins one global
@@ -87,8 +88,9 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
# PlatformIO shell-lexes each build.flags entry
flag_tokens = lex_build_flags(build.get("flags", []), f"library {name}")
# PIO precedence: build.libArchive, else the Arduino-format
# dot_a_linkage property, else archive (PlatformIO's default)
# build.libArchive is PIO behavior; dot_a_linkage is honored as a
# deliberate extra (Arduino IDE's property, which PIO ignores) so
# properties-only libraries can opt out of archiving too
if "libArchive" in build:
lib_archive = bool(build["libArchive"])
elif "dot_a_linkage" in data:
@@ -245,11 +247,19 @@ def resolve_libraries(
try:
check_library_data(dep, pio_platform, "arduino")
except InvalidLibrary as err:
# check_library_data's only raise is the platform filter, and
# rejecting another platform's dependency of a cross-platform
# manifest is routine (every ESPAsyncWebServer build hits it);
# a warning here would be noise, and the reason is in the log.
_LOGGER.debug("Skipping bundled dependency %s: %s", name, err)
# Rejecting another platform's dependency of a cross-platform
# manifest is routine (every ESPAsyncWebServer build hits
# it), so the platform filter stays at debug; any other
# cause means a dropped dependency and must be visible
if "platform" in str(err).lower():
_LOGGER.debug("Skipping bundled dependency %s: %s", name, err)
else:
_LOGGER.warning(
"Skipping bundled dependency %s of %s: %s",
name,
component.name,
err,
)
continue
bundled_names.add(name)
bundled.append(_bundled_library(framework_path, name))
@@ -266,7 +276,7 @@ def resolve_libraries(
_add_bundled_dependencies(component)
if external:
convert_libraries(
resolved = convert_libraries(
external,
LibraryBackend(
platform=pio_platform,
@@ -275,5 +285,14 @@ def resolve_libraries(
cache_key=cache_key,
),
)
if len(resolved) < len(external):
# A requested library the converter dropped would otherwise
# surface only as link errors far from the cause
_LOGGER.warning(
"%d of %d requested libraries were not resolved (resolved: %s)",
len(external) - len(resolved),
len(external),
", ".join(sorted(c.name for c in resolved)) or "none",
)
return bundled + converted
+5 -2
View File
@@ -68,9 +68,12 @@ def framework_package_version(ver: Version) -> str:
package that cannot exist.
"""
if ver.major > 3:
# Backend-neutral: this also fires on the PlatformIO validation path
# (via _format_framework_arduino_version), where switching toolchains
# would not help
raise EsphomeError(
f"Arduino core {ver} has no known package encoding; "
"use 'toolchain: platformio'"
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
)
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
+37 -8
View File
@@ -208,7 +208,7 @@ class _BuildConfig:
mmu_defines: list[str] = field(default_factory=list)
def _flag_defines() -> dict[str, str]:
def _flag_defines(unflags: set[str]) -> dict[str, str]:
"""Map define name -> full ``NAME[=VALUE]`` for every -D build flag."""
defines: dict[str, str] = {}
# Sorted so duplicate defines resolve the same way every run: the
@@ -220,6 +220,10 @@ def _flag_defines() -> dict[str, str]:
# read identically to _project_flags (and the compile line).
tokens = join_flag_args(split_flag_entry(flag, "esphome"), "esphome")
for tok in tokens:
# An unflagged knob must not drive lwIP/SDK/MMU selection while
# being absent from the compile line
if tok in unflags:
continue
if tok.startswith("-D") and len(tok) > 2:
body = tok[2:]
defines[body.split("=", 1)[0]] = body
@@ -250,10 +254,13 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
# Sorted so the pick is deterministic: the dict is built from a set of
# build flags, whose iteration order varies between processes.
vtables = next(
(name for name in sorted(defines) if name.startswith("VTABLES_IN_")),
"VTABLES_IN_FLASH",
)
vtables_knobs = sorted(name for name in defines if name.startswith("VTABLES_IN_"))
known_vtables = {"VTABLES_IN_FLASH", "VTABLES_IN_DRAM", "VTABLES_IN_IRAM"}
if unknown := [k for k in vtables_knobs if k not in known_vtables]:
_LOGGER.warning("Unknown VTABLES_IN_* define(s): %s", ", ".join(unknown))
if len(vtables_knobs) > 1:
_LOGGER.warning("Multiple VTABLES_IN_* defines; using %s", vtables_knobs[0])
vtables = vtables_knobs[0] if vtables_knobs else "VTABLES_IN_FLASH"
mmu = next((variant for knob, variant in _MMU_VARIANTS if knob in defines), None)
if mmu is None:
@@ -333,10 +340,16 @@ def _defines_flags(
def _unflag_tokens() -> set[str]:
"""``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches."""
# Joined like _project_flags reads build_flags, so "-D FOO" removes
# -DFOO in both spellings (PlatformIO's ProcessUnFlags parses the same
# way) and no bare half can collaterally drop an unrelated token
return {
tok
for entry in CORE.build_unflags
for tok in split_flag_entry(entry, "esphome build_unflags")
for tok in join_flag_args(
split_flag_entry(entry, "esphome build_unflags"),
"esphome build_unflags",
)
}
@@ -361,8 +374,15 @@ def _project_flags(
if tok.startswith("-Wl,"):
link_flags.append(_shell_token(tok))
elif tok.startswith("-L"):
if len(tok) == 2:
# Path("") is the CWD; never add it silently
_LOGGER.warning("Ignoring empty -L in build_flags")
continue
lib_dirs.append(Path(tok[2:]))
elif tok.startswith("-l"):
if len(tok) == 2:
_LOGGER.warning("Ignoring empty -l in build_flags")
continue
libs.append(tok[2:])
else:
compile_flags.append(_shell_token(tok))
@@ -431,6 +451,15 @@ def generate_ld_scripts(
) from err
if result.returncode != 0:
raise EsphomeError(f"Generating the linker script failed:\n{result.stderr}")
if result.stderr.strip():
# Preprocessor warnings on the success path must reach the user
_LOGGER.warning("Linker-script preprocessor: %s", result.stderr.strip())
if "SECTIONS" not in result.stdout:
# A degenerate zero-exit run must not be stamped as a good cache
raise EsphomeError(
"Generated linker script is missing its SECTIONS block; "
"run 'esphome clean-all' and retry"
)
content = build_surgery.relocate_ratetable(result.stdout)
if CORE.testing_mode:
content = build_surgery.apply_testing_memory_patches(
@@ -492,7 +521,8 @@ def write_project(paths: InstalledPaths) -> bool:
build_dir = CORE.relative_pioenvs_path(CORE.name)
mkdir_p(build_dir)
flag_defines = _flag_defines()
unflags = _unflag_tokens()
flag_defines = _flag_defines(unflags)
config = _resolve_build_config(flag_defines)
esp8266_data = CORE.data[KEY_ESP8266]
board = esp8266_data[KEY_BOARD]
@@ -538,7 +568,6 @@ def write_project(paths: InstalledPaths) -> bool:
for lib in libraries:
include_dirs += lib.include_dirs
unflags = _unflag_tokens()
(
project_compile_flags,
project_link_flags,
+6 -4
View File
@@ -1,9 +1,11 @@
"""Shared ccache policy for build backends.
One place for the ``CCACHE_*`` defaults (every backend) and for the probe
and enable rules (backends that call ``resolve_ccache_path``: PlatformIO
and the native Arduino build). The ESP-IDF backend keeps its own
``IDF_CCACHE_ENABLE`` gate and does not probe.
``ccache_defaults_env`` serves the backends that export ``CCACHE_*`` into a
build subprocess (native ESP-IDF and Arduino); ``resolve_ccache_path``
carries the probe and enable rules (PlatformIO and the native Arduino
build). The ESP-IDF backend keeps its own ``IDF_CCACHE_ENABLE`` gate and
does not probe; PlatformIO feeds its SCons wrapper script through env
channels instead of ``CCACHE_*`` defaults.
"""
from __future__ import annotations
+42 -6
View File
@@ -2,25 +2,61 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
from esphome.core import EsphomeError
from esphome.framework_helpers import strip_win_long_path_prefix
_LOGGER = logging.getLogger(__name__)
def _ninja_runs(binary: str) -> bool:
"""Whether the ninja found on PATH actually runs.
Same rationale as the ccache probe: ``shutil.which`` proves existence,
not runnability (stale shims, broken wrappers).
"""
try:
subprocess.run(
[binary, "--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
# Repo-wide convention (posix_spawn fast path)
close_fds=False,
)
except (OSError, subprocess.SubprocessError):
_LOGGER.warning(
"Ignoring ninja at %s because it failed to run; "
"falling back to the bundled wheel",
binary,
)
return False
return True
def find_ninja() -> Path:
"""Locate the ninja binary: PATH first, else the ninja PyPI wheel.
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
PyPI wheel.
The wheel is a requirements.txt dependency, so pip has already
integrity-checked it; no download logic is needed here.
"""
if binary := shutil.which("ninja"):
return Path(binary)
binary = strip_win_long_path_prefix(binary)
if _ninja_runs(binary):
return Path(binary)
import_error: ImportError | None = None
try:
import ninja
except ImportError:
except ImportError as err:
import_error = err
wheel_binary = None
else:
wheel_binary = Path(ninja.BIN_DIR) / (
@@ -30,11 +66,11 @@ def find_ninja() -> Path:
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
)
) from import_error
return wheel_binary
def escape(value) -> str:
def escape(value: Path | str) -> str:
"""Escape a path or token for a ninja file."""
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
@@ -76,6 +112,6 @@ def shell_token(tok: str, force: bool = False) -> str:
return tok
def quote_path(value) -> str:
def quote_path(value: Path | str) -> str:
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
return shell_token(str(value), force=True)
+13 -2
View File
@@ -81,7 +81,12 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None]
for ver in versions:
if ver.get("name") != version:
continue
for file in ver.get("files", []):
files = ver.get("files")
if not isinstance(files, list):
raise EsphomeError(
f"Unexpected package registry response for {package}: {str(ver)[:200]}"
)
for file in files:
# Only a MISSING key means "any system"; an explicitly empty
# list must not match (a wrong-architecture download would be
# cached as a good install). A bare string would make ``in`` a
@@ -100,7 +105,13 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None]
f"The package registry returned no sha256 for "
f"{package} {version}; refusing the unverified download"
)
return (file["download_url"], sha256, file.get("size"))
url = file.get("download_url")
if not url:
raise EsphomeError(
f"The package registry returned no download URL for "
f"{package} {version}"
)
return (url, sha256, file.get("size"))
raise EsphomeError(
f"No {package} {version} build for this platform ({systype})"
)
+121 -18
View File
@@ -70,7 +70,7 @@ def test_rule_map_covers_all_source_suffixes() -> None:
def test_build_config_defaults() -> None:
_set_flags()
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert config.nonosdk == "NONOSDK22x_190703"
assert config.lwip_lib == "lwip2-536-feat"
assert not config.exceptions
@@ -89,7 +89,7 @@ def test_build_config_esphome_lwip_knob() -> None:
as the PlatformIO builder."""
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert config.lwip_lib == "lwip2-1460"
assert "TCP_MSS=1460" in config.knob_defines
assert "LWIP_FEATURES=0" in config.knob_defines
@@ -104,7 +104,7 @@ def test_build_config_knobs() -> None:
"-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48",
"-DVTABLES_IN_DRAM",
)
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert config.nonosdk == "NONOSDK305"
assert config.exceptions
assert config.vtables == "VTABLES_IN_DRAM"
@@ -115,14 +115,14 @@ def test_build_config_mmu_custom_requires_sizes() -> None:
_set_flags("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM")
with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"):
_resolve_build_config(_flag_defines())
_resolve_build_config(_flag_defines(set()))
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
"-DMMU_IRAM_SIZE=0xC000",
"-DMMU_ICACHE_SIZE=0x4000",
)
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
# Emitted pre-sorted so build.ninja stays byte-stable across runs
assert config.mmu_defines == [
"MMU_ICACHE_SIZE=0x4000",
@@ -135,7 +135,7 @@ def test_defines_match_platformio_builder() -> None:
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
assert _defines_flags(
_resolve_build_config(_flag_defines()),
_resolve_build_config(_flag_defines(set())),
"dout",
"nodemcuv2",
ESP8266_BOARD_BUILD["nodemcuv2"]["defines"],
@@ -325,7 +325,7 @@ def test_build_config_lwip_variants(
"""Every lwIP knob maps to the same defines and library as the PIO builder."""
_set_flags(f"-D{knob}")
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert config.lwip_lib == lib
assert f"TCP_MSS={mss}" in config.knob_defines
assert f"LWIP_FEATURES={features}" in config.knob_defines
@@ -361,13 +361,13 @@ def test_build_config_lwip_variants(
def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None:
_set_flags(f"-D{knob}")
assert _resolve_build_config(_flag_defines()).mmu_defines == expected
assert _resolve_build_config(_flag_defines(set())).mmu_defines == expected
def test_build_config_waveform_locked_phase() -> None:
_set_flags("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM")
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines
assert config.fp_in_irom
@@ -389,7 +389,7 @@ SECTIONS
def _run_generate_ld_scripts(paths: InstalledPaths) -> Path:
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld")
return CORE.relative_pioenvs_path(CORE.name, "ld")
@@ -556,7 +556,7 @@ def test_build_config_nonosdk_precedence() -> None:
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305",
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221",
)
assert _resolve_build_config(_flag_defines()).nonosdk == "NONOSDK221"
assert _resolve_build_config(_flag_defines(set())).nonosdk == "NONOSDK221"
def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None:
@@ -601,7 +601,7 @@ def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None:
def test_flag_defines_lexes_multi_token_entries() -> None:
"""A knob inside a multi-token entry is detected like PlatformIO does."""
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os")
defines = _flag_defines()
defines = _flag_defines(set())
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
assert defines["FOO"] == "FOO=1"
config = _resolve_build_config(defines)
@@ -654,7 +654,7 @@ def test_write_project_empty_core_raises(tmp_path: Path) -> None:
def test_flag_defines_joins_spaced_define() -> None:
"""A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it."""
_set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
defines = _flag_defines()
defines = _flag_defines(set())
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
assert "" not in defines
@@ -718,7 +718,7 @@ def test_build_config_custom_mmu_without_knob_warns(
"""Custom MMU sizes without the CUSTOM knob keep the default layout and
warn, as the PlatformIO builder does."""
_set_flags("-DMMU_IRAM_SIZE=0xC000")
config = _resolve_build_config(_flag_defines())
config = _resolve_build_config(_flag_defines(set()))
assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"]
assert "Detected custom MMU flags" in caplog.text
@@ -726,14 +726,14 @@ def test_build_config_custom_mmu_without_knob_warns(
def test_flag_defines_lexes_quoted_single_tokens() -> None:
"""A quoted single-token define reads the same as on the compile line."""
_set_flags('-DMMU_SEC_HEAP="0x40108000"')
assert _flag_defines()["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000"
assert _flag_defines(set())["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000"
def test_flag_defines_duplicate_defines_resolve_deterministically() -> None:
"""Duplicate conflicting defines pick the same winner every run (sorted
iteration, last writer wins), independent of the set's hash seed."""
_set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000")
assert _flag_defines()["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000"
assert _flag_defines(set())["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000"
def test_flag_tables_match_platformio_builder() -> None:
@@ -748,20 +748,56 @@ def test_flag_tables_match_platformio_builder() -> None:
"-fno-inline-functions",
"-nostdlib",
]
assert arduino8266._CCFLAGS[:6] == [
assert arduino8266._CCFLAGS == [
"-Os",
"-mlongcalls",
"-mtext-section-literals",
"-falign-functions=4",
"-U__STRICT_ANSI__",
"-ffunction-sections",
"-fdata-sections",
"-Wall",
"-Werror=return-type",
"-free",
"-fipa-pta",
]
assert arduino8266._LINKFLAGS[:5] == [
# The -u block is where the deliberate -u _scanf_float omission lives;
# pinned in full so "restoring" it fails here first
assert arduino8266._LINKFLAGS == [
"-Os",
"-nostdlib",
"-Wl,--no-check-sections",
"-Wl,-static",
"-Wl,--gc-sections",
"-Wl,-wrap,system_restart_local",
"-Wl,-wrap,spi_flash_read",
"-u",
"app_entry",
"-u",
"_printf_float",
"-u",
"_DebugExceptionVector",
"-u",
"_DoubleExceptionVector",
"-u",
"_KernelExceptionVector",
"-u",
"_NMIExceptionVector",
"-u",
"_UserExceptionVector",
]
# Order is load-bearing: upstream's LIBS order resolves symbols correctly
assert arduino8266._SYSTEM_LIBS_PRE_LWIP == ["hal", "phy", "pp", "net80211"]
assert arduino8266._SYSTEM_LIBS_POST_LWIP == [
"wpa",
"crypto",
"main",
"wps",
"bearssl",
"espnow",
"smartconfig",
"airkiss",
"wpa2",
]
@@ -816,3 +852,70 @@ def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None:
CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board"
with pytest.raises(EsphomeError, match="'not_a_board' is not supported"):
_write_ninja(paths)
def test_unflag_tokens_join_spaced_entries() -> None:
"""A spaced build_unflags entry ("-D FOO") removes -DFOO, and no bare half leaks into the
unflag set to collaterally drop unrelated tokens."""
CORE.build_unflags = {"-D FOO", "-l bar"}
tokens = arduino8266._unflag_tokens()
assert tokens == {"-DFOO", "-lbar"}
CORE.build_flags = {"-DFOO -lbar", "-DBAR"}
compile_flags, _link, _dirs, libs = arduino8266._project_flags(tokens)
assert compile_flags == ["-DBAR"]
assert libs == []
def test_flag_defines_respects_unflags() -> None:
"""An unflagged knob must not drive the derived toolchain config."""
_set_flags("-DVTABLES_IN_DRAM")
defines = _flag_defines({"-DVTABLES_IN_DRAM"})
assert "VTABLES_IN_DRAM" not in defines
config = _resolve_build_config(defines)
assert config.vtables == "VTABLES_IN_FLASH"
def test_vtables_unknown_and_conflicting_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
_set_flags("-DVTABLES_IN_BANANA", "-DVTABLES_IN_DRAM")
config = _resolve_build_config(_flag_defines(set()))
assert "Unknown VTABLES_IN_*" in caplog.text
assert "Multiple VTABLES_IN_*" in caplog.text
# Deterministic pick, as before
assert config.vtables == "VTABLES_IN_BANANA"
def test_project_flags_empty_lib_flags_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A bare -L must not silently add the CWD to the search path."""
CORE.build_flags = {'-L ""', '-l ""'}
_c, _l, lib_dirs, libs = arduino8266._project_flags(set())
assert lib_dirs == []
assert libs == []
assert "Ignoring empty -L" in caplog.text
assert "Ignoring empty -l" in caplog.text
def test_generate_ld_scripts_surfaces_preprocessor_warnings(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Preprocessor stderr on a zero exit reaches the user; degenerate output is refused."""
paths = _make_framework(tmp_path)
_set_flags()
result = MagicMock(
returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warning: something"
)
with patch.object(arduino8266.subprocess, "run", return_value=result):
_run_generate_ld_scripts(paths)
assert "Linker-script preprocessor: warning: something" in caplog.text
# New flags invalidate the stamp so the degenerate run regenerates
_set_flags("-DVTABLES_IN_DRAM")
result = MagicMock(returncode=0, stdout="", stderr="")
with (
patch.object(arduino8266.subprocess, "run", return_value=result),
pytest.raises(EsphomeError, match="SECTIONS"),
):
_run_generate_ld_scripts(paths)
+32 -1
View File
@@ -14,7 +14,10 @@ from esphome.core import EsphomeError
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
with patch("shutil.which", return_value=str(tmp_path / "ninja")):
with (
patch("shutil.which", return_value=str(tmp_path / "ninja")),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "ninja"
@@ -81,3 +84,31 @@ def test_quote_path_force_quotes() -> None:
def test_shell_token_empty_token_is_quoted() -> None:
"""An empty argv element must survive as an explicit pair of quotes."""
assert ninja_helper.shell_token("") == '""'
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
"""A broken PATH shim falls back to the wheel instead of failing every
build later."""
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
(tmp_path / binary_name).touch()
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value="/broken/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=False),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
with patch(
"esphome.build_helpers.ninja.subprocess.run", side_effect=OSError("boom")
):
assert ninja_helper._ninja_runs("/broken/ninja") is False
assert "failed to run" in caplog.text
def test_ninja_probe_success() -> None:
with patch("esphome.build_helpers.ninja.subprocess.run") as mock_run:
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
assert mock_run.call_args.kwargs["close_fds"] is False
+14 -1
View File
@@ -25,10 +25,23 @@ def test_framework_package_version() -> None:
# 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path)
assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0"
# A future major bump needs its own encoding, not a doomed registry lookup
with pytest.raises(EsphomeError, match="no known package encoding"):
with pytest.raises(EsphomeError, match="not supported yet"):
framework.framework_package_version(cv.Version(4, 0, 0))
def test_format_framework_arduino_version_pins_all_series() -> None:
"""The esp8266 component's PIO source formatter across every encoding
era, including the 4.x rejection it now shares with the installer."""
from esphome.components.esp8266 import _format_framework_arduino_version as fmt
assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0"
assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0"
assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0"
assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0"
with pytest.raises(EsphomeError, match="not supported yet"):
fmt(cv.Version(4, 0, 0))
def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}):
assert framework.get_arduino8266_tools_path() == tmp_path.resolve()
+49
View File
@@ -409,3 +409,52 @@ def test_resolve_libraries_dep_warnings(
assert "malformed dependency entry" in caplog.text
assert "Orphan" in caplog.text
assert "owner but no version" in caplog.text
def test_resolve_libraries_warns_when_converter_drops_a_request(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A requested external library the converter drops is named, not lost."""
framework = _make_framework(tmp_path)
_add_library("pngle", "1.0.0")
with patch.object(component, "convert_libraries", return_value=[]):
component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "1 of 1 requested libraries were not resolved" in caplog.text
def test_bundled_dependency_nonplatform_rejection_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An InvalidLibrary whose cause is not the platform filter is visible."""
from esphome.platformio.library import InvalidLibrary
framework = _make_framework(tmp_path)
_add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
lib_dir = tmp_path / "converted" / "webserver"
(lib_dir / "src").mkdir(parents=True)
converted = _converted(
"esp32async__ESPAsyncWebServer",
lib_dir,
{"build": {}, "dependencies": [{"name": "Wire"}]},
)
with (
_emitting_converter(converted),
patch.object(
component,
"check_library_data",
side_effect=InvalidLibrary("manifest is corrupt"),
),
):
component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "Skipping bundled dependency Wire" in caplog.text
assert "manifest is corrupt" in caplog.text
@@ -346,3 +346,21 @@ def test_registry_download_missing_system_key_matches_any() -> None:
[{"download_url": "http://x/any", "checksum": {"sha256": "abc"}, "size": 1}]
):
assert registry.registry_download("pkg", "1.0.0") == ("http://x/any", "abc", 1)
def test_registry_download_missing_files_list_is_named() -> None:
"""A version entry without a files list is an unexpected payload, not a
missing platform build."""
with (
_registry_response(None),
pytest.raises(EsphomeError, match="Unexpected package registry response"),
):
registry.registry_download("pkg", "1.0.0")
def test_registry_download_missing_download_url_is_named() -> None:
with (
_registry_response([{"system": "*", "checksum": {"sha256": "abc"}, "size": 1}]),
pytest.raises(EsphomeError, match="no download URL"),
):
registry.registry_download("pkg", "1.0.0")