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

This commit is contained in:
J. Nick Koston
2026-08-20 15:49:05 -05:00
16 changed files with 247 additions and 207 deletions
+4 -2
View File
@@ -142,7 +142,7 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
else:
manifest = lib_dir / "library.properties"
data = parse_library_properties(manifest) if manifest.is_file() else {}
return _library_info(name, lib_dir, {"name": name, **data})
return _library_info(name, lib_dir, data)
def resolve_libraries(
@@ -219,7 +219,9 @@ def resolve_libraries(
bundled.append(_bundled_library(framework_path, name))
def _emit(component: ConvertedLibrary) -> None:
apply_extra_script(component, board_mcu=board_mcu, pio_platform=pio_platform)
apply_extra_script(
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
)
converted.append(
_library_info(
component.get_require_name(), component.source_dir, component.data
+12 -29
View File
@@ -22,8 +22,9 @@ import functools
import logging
import os
from pathlib import Path
import shutil
from typing import NamedTuple
from esphome.build_helpers.ninja import find_ninja
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import (
ccache_defaults_env,
@@ -78,31 +79,15 @@ def get_toolchain_path() -> Path:
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
def _find_ninja() -> Path:
"""Locate the ninja binary: PATH first, else the ninja PyPI wheel.
class InstalledPaths(NamedTuple):
"""Locations of the installed framework, toolchain, and ninja binary."""
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)
try:
import ninja
except ImportError:
wheel_binary = None
else:
wheel_binary = Path(ninja.BIN_DIR) / (
"ninja.exe" if os.name == "nt" else "ninja"
)
if wheel_binary is None or not wheel_binary.is_file():
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
)
return wheel_binary
framework: Path
toolchain: Path
ninja: Path
def check_and_install(framework_version: Version) -> dict[str, Path]:
def check_and_install(framework_version: Version) -> InstalledPaths:
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
if framework_version < MIN_FRAMEWORK_VERSION:
# Config validation enforces this too; keep the module honest when
@@ -112,7 +97,7 @@ def check_and_install(framework_version: Version) -> dict[str, Path]:
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
)
# Probe the cheap local dependency before ~110 MB of downloads
ninja_path = _find_ninja()
ninja_path = find_ninja()
package_version = framework_package_version(framework_version)
framework_path = get_framework_path(package_version)
downloads_dir = get_arduino8266_tools_path() / "downloads"
@@ -133,11 +118,9 @@ def check_and_install(framework_version: Version) -> dict[str, Path]:
downloads_dir,
expect=("bin",),
)
return {
"framework_path": framework_path,
"toolchain_path": toolchain_path,
"ninja_path": ninja_path,
}
return InstalledPaths(
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
)
def get_build_env(toolchain_path: Path) -> dict[str, str]:
+21 -57
View File
@@ -18,10 +18,15 @@ from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING
from esphome.build_helpers.ninja import (
escape as _e,
quote_path as _q,
shell_token as _shell_token,
)
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import (
BOARDS,
@@ -45,6 +50,9 @@ from esphome.platformio.library import (
split_flag_entry,
)
if TYPE_CHECKING:
from esphome.arduino8266.framework import InstalledPaths
_LOGGER = logging.getLogger(__name__)
# Compile rule per source suffix, derived from the shared suffix -> kind map
@@ -285,48 +293,6 @@ def _flash_ld_name(board: str) -> str:
return ESP8266_LD_SCRIPTS[BOARDS[board][KEY_FLASH_SIZE]][1]
def _e(value) -> str:
"""Escape a path or token for a ninja file."""
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
def _quote_arg(tok: str) -> str:
"""Wrap a token in double quotes with the Windows argv rule.
Same escaping rule as ``subprocess.list2cmdline``: a backslash run
doubles only immediately before a quote (or the closing quote), and the
quote itself is escaped. POSIX sh parses the result identically for
backslashes and quotes. ``$`` must already be doubled for ninja.
"""
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
return f'"{quoted}"'
_NEEDS_QUOTE = re.compile(r'[\s"\']')
def _shell_token(tok: str, force: bool = False) -> str:
"""Quote a lexed token only when needed; ``force`` always quotes.
Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single
token ``-DX=a b``); re-quote on the way out so the compiler receives the
same argv element SCons would pass under PlatformIO. After ninja
un-doubles ``$$``, sh still expands ``$VAR`` while CreateProcess passes
it literally -- the same divergence SCons-under-sh has, so this stays
PlatformIO parity.
"""
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
if force or _NEEDS_QUOTE.search(tok):
return _quote_arg(tok)
return tok
def _q(value) -> str:
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
return _shell_token(str(value), force=True)
def _defines_flags(
config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...]
) -> list[str]:
@@ -399,7 +365,7 @@ def _collect_sources(root: Path, exclude: set[str] = frozenset()) -> list[Path]:
def generate_ld_scripts(
paths: dict[str, Path], config: _BuildConfig, flash_ld_name: str
paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str
) -> None:
"""Generate the common linker script (and testing-mode flash ld copy).
@@ -407,8 +373,8 @@ def generate_ld_scripts(
``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi
rate-table DRAM relocation, and enlarged memory segments in testing mode.
"""
framework = paths["framework_path"]
gcc = paths["toolchain_path"] / "bin" / "xtensa-lx106-elf-gcc"
framework = paths.framework
gcc = paths.toolchain / "bin" / "xtensa-lx106-elf-gcc"
ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld")
mkdir_p(ld_dir)
@@ -493,7 +459,7 @@ def _common_parent(paths: list[Path]) -> Path:
return Path(os.path.commonpath([str(p.parent) for p in paths]))
def write_project(paths: dict[str, Path]) -> bool:
def write_project(paths: InstalledPaths) -> bool:
"""Write the ninja build for the current configuration.
Returns True when ``build.ninja`` changed, so the caller can skip work
@@ -502,8 +468,8 @@ def write_project(paths: dict[str, Path]) -> bool:
from esphome.arduino.library import resolve_libraries
from esphome.arduino8266.framework import ccache_path
framework = paths["framework_path"]
toolchain_bin = paths["toolchain_path"] / "bin"
framework = paths.framework
toolchain_bin = paths.toolchain / "bin"
build_dir = CORE.relative_pioenvs_path(CORE.name)
mkdir_p(build_dir)
@@ -535,7 +501,7 @@ def write_project(paths: dict[str, Path]) -> bool:
src_dir,
sdk / "include",
core_dir,
paths["toolchain_path"] / "include",
paths.toolchain / "include",
sdk / "lwip2" / "include",
variant_dir,
]
@@ -639,7 +605,7 @@ def write_project(paths: dict[str, Path]) -> bool:
" rspfile_content = $in_newline",
" description = LINK $out",
"rule elf2bin",
f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {esp8266_data[KEY_FLASH_MODE]} --flash_freq 40 --flash_size {_flash_size_str(flash_ld_name)} --path {_q(toolchain_bin)} --out $out",
f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {esp8266_data[KEY_FLASH_MODE]} --flash_freq 40 --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out",
" description = BIN $out",
"rule copy",
" command = $python $buildtool copy $in $out",
@@ -737,9 +703,7 @@ def get_flash_ld_path(build_dir: Path) -> Path:
return get_framework_path(version) / "tools" / "sdk" / "ld" / name
def _flash_size_str(flash_ld_name: str) -> str:
"""Flash size for elf2bin, derived from the ld script name (PIO logic)."""
match = re.search(r"\.flash\.(\d+)([mk])", flash_ld_name)
if not match:
raise EsphomeError(f"Cannot parse flash size from {flash_ld_name}")
return f"{match.group(1)}{match.group(2).upper()}"
def _flash_size_str(flash_size: int) -> str:
"""Flash size argument for elf2bin (e.g. ``4M``, ``512K``)."""
mb = 1024 * 1024
return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K"
+76
View File
@@ -0,0 +1,76 @@
"""Platform-neutral helpers for ninja-driven native builds."""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
from esphome.core import EsphomeError
def find_ninja() -> Path:
"""Locate the ninja binary: PATH 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)
try:
import ninja
except ImportError:
wheel_binary = None
else:
wheel_binary = Path(ninja.BIN_DIR) / (
"ninja.exe" if os.name == "nt" else "ninja"
)
if wheel_binary is None or not wheel_binary.is_file():
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
)
return wheel_binary
def escape(value) -> str:
"""Escape a path or token for a ninja file."""
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
def quote_arg(tok: str) -> str:
"""Wrap a token in double quotes with the Windows argv rule.
Same escaping rule as ``subprocess.list2cmdline``: a backslash run
doubles only immediately before a quote (or the closing quote), and the
quote itself is escaped. POSIX sh parses the result identically for
backslashes and quotes. ``$`` must already be doubled for ninja.
"""
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
return f'"{quoted}"'
_NEEDS_QUOTE = re.compile(r'[\s"\']')
def shell_token(tok: str, force: bool = False) -> str:
"""Quote a lexed token only when needed; ``force`` always quotes.
Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single
token ``-DX=a b``); re-quote on the way out so the compiler receives the
same argv element SCons would pass under PlatformIO. After ninja
un-doubles ``$$``, sh still expands ``$VAR`` while CreateProcess passes
it literally -- the same divergence SCons-under-sh has, so this stays
PlatformIO parity.
"""
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
if force or _NEEDS_QUOTE.search(tok):
return quote_arg(tok)
return tok
def quote_path(value) -> str:
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
return shell_token(str(value), force=True)
+5 -14
View File
@@ -1071,20 +1071,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
return config
def _validate_toolchain(value) -> Toolchain:
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
)
def _resolve_toolchain(value: ConfigType) -> ConfigType:
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
# Runs before _detect_variant so downstream validators can rely on
# CORE.toolchain instead of re-resolving it from the config dict.
if CORE.toolchain is None:
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF)
cv.check_supported_toolchain("ESP32", (Toolchain.PLATFORMIO, Toolchain.ESP_IDF))
return value
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
# Runs before _detect_variant so downstream validators can rely on
# CORE.toolchain instead of re-resolving it from the config dict.
_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF)
def _check_versions(config: ConfigType) -> ConfigType:
+3 -9
View File
@@ -125,11 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config
def _resolve_toolchain(config: ConfigType) -> ConfigType:
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
cv.check_supported_toolchain("nRF52", (Toolchain.PLATFORMIO, Toolchain.SDK_NRF))
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
def set_framework(config: ConfigType) -> ConfigType:
@@ -171,10 +168,7 @@ BOOTLOADERS = [
]
def _validate_toolchain(value) -> Toolchain:
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
def _detect_bootloader(config: ConfigType) -> ConfigType:
+32 -8
View File
@@ -53,6 +53,7 @@ from esphome.const import (
CONF_SETUP_PRIORITY,
CONF_STATE_TOPIC,
CONF_SUBSCRIBE_QOS,
CONF_TOOLCHAIN,
CONF_TOPIC,
CONF_TYPE,
CONF_TYPE_ID,
@@ -2555,20 +2556,43 @@ def check_supported_toolchain(
)
def toolchain_enum(supported: tuple[Toolchain, ...]):
"""Schema validator for a platform's ``toolchain`` config key."""
def validator(value) -> Toolchain:
return Toolchain(one_of(*supported, lower=True)(value))
return validator
def resolve_toolchain(
platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain
):
"""Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the
platform cannot serve.
Add to the platform's validation chain before anything that reads
``CORE.toolchain``.
"""
def validator(config: ConfigType) -> ConfigType:
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, default)
check_supported_toolchain(platform_name, supported)
return config
return validator
def require_platformio_toolchain(platform_name: str):
"""Reject a CLI-selected toolchain other than PlatformIO.
For platforms with only the PlatformIO backend; without this a
``--toolchain`` they cannot serve would silently build with PlatformIO.
"""
def validator(config: ConfigType) -> ConfigType:
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
check_supported_toolchain(platform_name, (Toolchain.PLATFORMIO,))
return config
return validator
return resolve_toolchain(
platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO
)
def require_framework_version(
+6
View File
@@ -25,6 +25,12 @@ class Toolchain(StrEnum):
ARDUINO = "arduino"
# Toolchains that drive their build natively and never read platformio.ini.
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
# platformio_options.
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO})
class Platform(StrEnum):
"""Platform identifiers for ESPHome."""
+7
View File
@@ -21,6 +21,7 @@ from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
NATIVE_TOOLCHAINS,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
@@ -992,6 +993,12 @@ class EsphomeCore:
"""
return self.toolchain == Toolchain.ARDUINO
@property
def using_native_toolchain(self):
"""Whether the selected toolchain builds natively, without reading
``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``)."""
return self.toolchain in NATIVE_TOOLCHAINS
@property
def using_zephyr(self):
return self.target_framework == "zephyr"
+1 -3
View File
@@ -557,9 +557,7 @@ def _add_library_str(lib: str) -> None:
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
# Every platform's toolchain validation rejects values it cannot serve,
# so using_toolchain_arduino by itself implies the native ESP8266 build.
if CORE.using_toolchain_esp_idf or CORE.using_toolchain_arduino:
if CORE.using_native_toolchain:
# The native builds don't read platformio.ini; honor the options
# with a native equivalent and warn about the rest, which would
# otherwise be silently ignored.
+8 -20
View File
@@ -11,10 +11,11 @@ import re
import shutil
from typing import Any, NoReturn
from esphome.core import CORE, Version
from esphome.core import Version
from esphome.framework_helpers import (
PathType,
archive_extract_all,
ccache_defaults_env,
create_venv,
download_from_mirrors,
download_with_resume,
@@ -1156,25 +1157,12 @@ def _ccache_env() -> dict[str, str]:
# ESP-IDF silently skips ccache without the binary; don't enable it.
return {}
# ccache is enabled past here. build_path is set during preload for every
# config-loading command, so it being unset means a caller built the IDF env
# too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which
# would quietly cost cross-device cache hits).
if CORE.build_path is None:
raise ValueError(
"CORE.build_path must be set before constructing the ESP-IDF build "
"environment"
)
defaults = {
"IDF_CCACHE_ENABLE": "1",
"CCACHE_DIR": str(get_idf_tools_path() / "ccache"),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
# Don't override CCACHE_* values the user already set in their environment.
return {k: v for k, v in defaults.items() if k not in os.environ}
# ccache is enabled past here; the shared helper carries the CCACHE_*
# policy (and the fail-loud build_path guard).
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
if "IDF_CCACHE_ENABLE" not in os.environ:
env["IDF_CCACHE_ENABLE"] = "1"
return env
def get_framework_env(
+10 -16
View File
@@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266.framework import InstalledPaths
from esphome.build_gen import arduino8266
from esphome.build_gen.arduino8266 import (
_defines_flags,
@@ -181,15 +182,11 @@ def _make_framework(tmp_path: Path) -> dict[str, Path]:
toolchain = tmp_path / "toolchain"
(toolchain / "bin").mkdir(parents=True)
(toolchain / "include").mkdir()
return {
"framework_path": framework,
"toolchain_path": toolchain,
"ninja_path": Path("ninja"),
}
return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja"))
def _write_ninja(
paths: dict[str, Path],
paths: InstalledPaths,
libraries: list | None = None,
ccache: str | None = None,
) -> str:
@@ -390,7 +387,7 @@ SECTIONS
"""
def _run_generate_ld_scripts(paths: dict[str, Path]) -> Path:
def _run_generate_ld_scripts(paths: InstalledPaths) -> Path:
config = _resolve_build_config(_flag_defines())
arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld")
@@ -442,7 +439,7 @@ def test_generate_ld_scripts_failure(tmp_path: Path) -> None:
def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
(paths["framework_path"] / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
(paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
"MEMORY\n{\n"
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
" iram1_0_seg : org = 0x40100000, len = 0x8000\n"
@@ -463,7 +460,7 @@ def test_write_project_libraries_and_variant(
from esphome.arduino.library import ArduinoLibrary
paths = _make_framework(tmp_path)
variant_src = paths["framework_path"] / "variants" / "nodemcu" / "variant.cpp"
variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp"
variant_src.write_text("")
lib_dir = tmp_path / "libsrc"
@@ -529,11 +526,8 @@ def test_get_flash_ld_path(tmp_path: Path) -> None:
def test_flash_size_str() -> None:
assert _flash_size_str("eagle.flash.4m.ld") == "4M"
assert _flash_size_str("eagle.flash.512k.ld") == "512K"
with pytest.raises(EsphomeError, match="Cannot parse flash size"):
_flash_size_str("bogus.ld")
assert _flash_size_str(4 * 1024 * 1024) == "4M"
assert _flash_size_str(512 * 1024) == "512K"
def test_write_project_testing_mode(tmp_path: Path) -> None:
@@ -550,7 +544,7 @@ def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None:
import shutil
paths = _make_framework(tmp_path)
shutil.rmtree(paths["framework_path"] / "tools" / "sdk" / "lwip2")
shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2")
_set_flags()
with pytest.raises(EsphomeError, match="incomplete.*lwip2"):
_write_ninja(paths)
@@ -663,7 +657,7 @@ def test_shell_token_escaping() -> None:
def test_write_project_empty_core_raises(tmp_path: Path) -> None:
"""A framework tree with no core sources fails at generation, not link."""
paths = _make_framework(tmp_path)
core = paths["framework_path"] / "cores" / "esp8266"
core = paths.framework / "cores" / "esp8266"
for f in core.iterdir():
f.unlink()
_set_flags()
@@ -0,0 +1,50 @@
"""Tests for esphome.build_helpers.ninja."""
from __future__ import annotations
import os
from pathlib import Path
import sys
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers import ninja as ninja_helper
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")):
assert ninja_helper.find_ninja() == tmp_path / "ninja"
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
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=None),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_find_ninja_package_not_installed() -> None:
"""A missing ninja package raises the actionable message, not ImportError."""
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": None}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
+5 -45
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@@ -36,55 +35,16 @@ def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
assert path != Path.cwd()
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
with patch("shutil.which", return_value=str(tmp_path / "ninja")):
assert framework._find_ninja() == tmp_path / "ninja"
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
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=None),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert framework._find_ninja() == tmp_path / binary_name
def test_find_ninja_package_not_installed() -> None:
"""A missing ninja package raises the actionable message, not ImportError."""
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": None}),
pytest.raises(EsphomeError, match="ninja not found"),
):
framework._find_ninja()
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
pytest.raises(EsphomeError, match="ninja not found"),
):
framework._find_ninja()
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
with (
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
patch.object(framework, "install_package") as mock_install,
patch.object(framework, "_find_ninja", return_value=tmp_path / "ninja"),
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
):
paths = framework.check_and_install(cv.Version(3, 1, 2))
assert paths["framework_path"] == tmp_path / "frameworks" / "3.30102.0"
assert (
paths["toolchain_path"] == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
)
assert paths["ninja_path"] == tmp_path / "ninja"
assert paths.framework == tmp_path / "frameworks" / "3.30102.0"
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
assert paths.ninja == tmp_path / "ninja"
assert mock_install.call_count == 2
# The layout checks cover the directories write_project needs, including
# the bundled libraries/ tree
+5 -3
View File
@@ -214,9 +214,11 @@ def test_resolve_libraries_external_and_bundled_deps(tmp_path: Path) -> None:
cache_key="arduino8266",
)
mock_extra.assert_called_once_with(
converted, board_mcu="esp8266", pio_platform="espressif8266"
)
mock_extra.assert_called_once()
assert mock_extra.call_args.args == (converted,)
assert mock_extra.call_args.kwargs["pio_platform"] == "espressif8266"
# board_mcu is passed lazily, as the shared helper requires
assert mock_extra.call_args.kwargs["board_mcu"]() == "esp8266"
assert [lib.name for lib in libs] == [
"Wire",
"esp32async__ESPAsyncWebServer",
+2 -1
View File
@@ -1400,8 +1400,9 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
# ccache_defaults_env (framework_helpers) reads CORE at call time
patch(
"esphome.espidf.framework.CORE",
"esphome.core.CORE",
SimpleNamespace(build_path=build_path),
),
)