diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 1c5840cd10..dee58b5fed 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -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 diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index e7b55e1b7c..cded1438c4 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -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" diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index deb48f6d54..3329a9c647 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -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, diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 87dd337396..70005f5d40 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -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 diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 3b16a9d57d..171462feb6 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -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) diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index e6db487c83..89cf76de10 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -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})" ) diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 87093ce3e7..4f4c96d0d5 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -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) diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index ea4998e107..981c740cde 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -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 diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 334f081bcb..c69f802c9e 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -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() diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 81e17490aa..f0bff7b729 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -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 diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 6c4ad83c4c..a8f895ad43 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -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")