diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 994179658c..475e61b6c0 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -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]: diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py new file mode 100644 index 0000000000..98af05a031 --- /dev/null +++ b/esphome/build_helpers/ninja.py @@ -0,0 +1,33 @@ +"""Platform-neutral helpers for ninja-driven native builds.""" + +from __future__ import annotations + +import os +from pathlib import Path +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 diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6c01058044..b4b03ce113 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -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( diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py new file mode 100644 index 0000000000..cf51d1b8d8 --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -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() diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index a9d554cec0..fac85964ce 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -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 diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d8e7738569..4c9e67a1c6 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -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), ), )