Add the ESP8266 Arduino framework and toolchain installer

This commit is contained in:
J. Nick Koston
2026-08-20 12:58:06 -05:00
parent 04ad9524f5
commit 3e77654719
5 changed files with 783 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
This package downloads the Arduino ESP8266 core and the xtensa-lx106
toolchain, generates a ninja build for them plus the ESPHome sources, and
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
Deliberately importable without the esp8266 component to avoid circular
imports; the component wires these modules in via lazy imports.
"""
+335
View File
@@ -0,0 +1,335 @@
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
Artifacts land in a machine-global cache (shared across projects, like the
ESP-IDF install in ``esphome.espidf.framework``):
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
ninja itself comes from PATH or the ninja PyPI wheel (a requirements.txt
dependency), so only the two packages above are downloaded here.
Sources default to the PlatformIO registry (the exact packages the PlatformIO
toolchain has always used, so the bits are identical); the
``ESPHOME_ARDUINO8266_*_MIRRORS`` environment variables override the URLs with
``{VERSION}`` / ``{SYSTEM}`` substitution.
"""
from __future__ import annotations
from collections.abc import Collection
import functools
import logging
import os
from pathlib import Path
import platform
import shutil
import platformdirs
import esphome.config_validation as cv
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import (
archive_extract_all,
download_from_mirrors,
download_with_resume,
rmdir,
str_to_lst_of_str,
)
from esphome.helpers import get_bool_env, get_str_env
from esphome.platformio.library import ensure_list
_LOGGER = logging.getLogger(__name__)
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
# gcc 10.3, the toolchain Arduino core 3.x builds with. The compile flags in
# the build generator are tuned to it; treat version changes as a full
# reinstall (the install dir is keyed on the version).
TOOLCHAIN_VERSION = "2.100300.220621"
_REGISTRY_URL = (
"https://api.registry.platformio.org/v3/packages/platformio/tool/{package}"
)
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
)
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
)
def get_arduino8266_tools_path() -> Path:
# Treat an empty/whitespace prefix as unset: Path("") resolves to the CWD,
# which clean-all would then delete.
if prefix := get_str_env("ESPHOME_ARDUINO8266_PREFIX", "").strip():
path = Path(prefix).expanduser()
else:
# Machine-global so all projects share one install; see
# espidf.framework.get_idf_tools_path for the location rationale.
path = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False))
/ "arduino8266"
)
return path.resolve()
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0
MIN_FRAMEWORK_VERSION = cv.Version(3, 1, 1)
def framework_package_version(ver: cv.Version) -> str:
"""Map an Arduino core version (e.g. 3.1.2) to its package version.
Same encoding as the PlatformIO package registry uses for core 3.x
releases (3.1.2 -> 3.30102.0). The native toolchain only supports core
>= MIN_FRAMEWORK_VERSION, so the 1.x/2.x encodings never apply here.
"""
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
def get_framework_path(package_version: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / package_version
def get_toolchain_path() -> Path:
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
def _downloads_path() -> Path:
path = get_arduino8266_tools_path() / "downloads"
path.mkdir(parents=True, exist_ok=True)
return path
def _pio_system() -> str:
"""The PlatformIO registry system tag for the current host.
Hand-rolled instead of ``platformio.util.get_systype()`` so this backend
never imports the PlatformIO package. The windows-arm64 and darwin-arm64
mappings are deliberate: the toolchain packages ship x86_64 binaries for
those hosts (Rosetta / x86 emulation).
"""
sysname = platform.system().lower()
machine = platform.machine().lower()
if sysname == "darwin":
return "darwin_arm64" if machine == "arm64" else "darwin_x86_64"
if sysname == "windows":
return "windows_amd64" if machine in ("amd64", "arm64") else "windows_x86"
if sysname == "linux":
if machine in ("arm64", "aarch64"):
return "linux_aarch64"
if machine in ("i686", "i386", "x86"):
return "linux_i686"
if machine.startswith("arm"):
return f"linux_{machine}"
if machine in ("x86_64", "amd64"):
return "linux_x86_64"
# Fail here, near the cause, rather than installing a toolchain whose
# binaries cannot execute on this host.
raise EsphomeError(
f"No {sysname}/{machine} build of the ESP8266 toolchain exists; "
"use 'toolchain: platformio'"
)
def _registry_download(package: str, version: str) -> tuple[str, str, int | None]:
"""Resolve a package's download URL, sha256, and size via the PIO registry."""
import requests
url = _REGISTRY_URL.format(package=package)
last_err: Exception | None = None
for _ in range(3):
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
data = resp.json()
break
except requests.RequestException as err:
last_err = err
else:
# A clean, retried error like the other download paths in the tree
raise EsphomeError(
f"Could not query the package registry for {package}: {last_err}"
) from last_err
system = _pio_system()
for ver in data.get("versions", []):
if ver.get("name") != version:
continue
for file in ver.get("files", []):
# ensure_list: a bare string would make ``in`` a substring test
systems = ensure_list(file.get("system") or "*")
if "*" in systems or system in systems:
sha256 = (file.get("checksum") or {}).get("sha256")
if not sha256:
# Never extract an unverified archive; the registry
# publishes a checksum for every package file.
raise EsphomeError(
f"The package registry returned no sha256 for "
f"{package} {version}; refusing the unverified download"
)
return (file["download_url"], sha256, file.get("size"))
raise EsphomeError(f"No {package} {version} build for this platform ({system})")
raise EsphomeError(f"{package} {version} not found in the package registry")
def _install_package(
name: str,
version: str,
dest: Path,
mirrors: list[str],
expect: Collection[str] = (),
) -> None:
"""Download, verify, and extract one package if not already installed.
The registry path is integrity-checked against the sha256 the registry
publishes; a mirror override is trusted as configured.
"""
marker = dest / ".esphome_extracted"
if marker.is_file():
return
from filelock import FileLock
# The cache is machine-global; serialize concurrent cold builds so one
# process cannot wipe the directory another is extracting into (same
# filelock pattern as platformio/toolchain.py and git.py).
dest.parent.mkdir(parents=True, exist_ok=True)
with FileLock(f"{dest}.lock"):
if marker.is_file():
# Another process finished the install while we waited
return
rmdir(dest, msg=f"Clean up incomplete {name} install")
# A persistent download location (not a temp dir) so an interrupted
# download resumes across esphome runs via download_with_resume's
# .part file, mirroring the espidf dist/ convention.
archive = _downloads_path() / f"{name}-{version}"
_LOGGER.info("Downloading %s %s ...", name, version)
if mirrors:
_LOGGER.warning(
"Downloading %s from a mirror override; checksum verification "
"is skipped for mirrors",
name,
)
download_from_mirrors(
mirrors, {"VERSION": version, "SYSTEM": _pio_system()}, archive
)
else:
url, sha256, size = _registry_download(name, version)
download_with_resume(url, archive, sha256=sha256, size=size)
_LOGGER.info("Extracting %s ...", name)
archive_extract_all(archive, dest, progress_header="Extracting")
# Validate the layout before recording success, so an unexpected
# package is never cached as a working install.
for rel in expect:
if not (dest / rel).is_dir():
raise EsphomeError(
f"{name} {version} extracted without the expected {rel} "
"directory; run 'esphome clean-all' and retry"
)
marker.touch()
archive.unlink(missing_ok=True)
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 as err:
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
) from err
binary = Path(ninja.BIN_DIR) / ("ninja.exe" if os.name == "nt" else "ninja")
if not binary.is_file():
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
)
return binary
def check_and_install(framework_version: cv.Version) -> dict[str, Path]:
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
package_version = framework_package_version(framework_version)
framework_path = get_framework_path(package_version)
_install_package(
FRAMEWORK_PACKAGE,
package_version,
framework_path,
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
expect=("cores/esp8266", "tools/sdk", "libraries"),
)
toolchain_path = get_toolchain_path()
_install_package(
TOOLCHAIN_PACKAGE,
TOOLCHAIN_VERSION,
toolchain_path,
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
expect=("bin",),
)
return {
"framework_path": framework_path,
"toolchain_path": toolchain_path,
"ninja_path": _find_ninja(),
}
def get_build_env(toolchain_path: Path) -> dict[str, str]:
env = os.environ.copy()
env["PATH"] = str(toolchain_path / "bin") + os.pathsep + env.get("PATH", "")
env.update(ccache_env())
return env
@functools.cache
def ccache_path() -> str | None:
"""The ccache binary to prefix compiles with, or None when disabled.
Same convention as the PlatformIO path: on by default when the binary is
on PATH, ``ESPHOME_CCACHE_ENABLE=0`` disables it, and an explicit ``=1``
warns when no binary is found and skips the runnability probe.
"""
from esphome.platformio.toolchain import _ccache_runs, _strip_win_long_path_prefix
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
return None
ccache = shutil.which("ccache")
if ccache is None:
if explicit:
_LOGGER.warning(
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
"compiling without ccache"
)
return None
ccache = _strip_win_long_path_prefix(ccache)
if not explicit and not _ccache_runs(ccache):
return None
return ccache
def ccache_env() -> dict[str, str]:
"""Return ccache settings for the build subprocess (not os.environ).
Mirrors ``espidf.framework._ccache_env``: cache under the machine-global
tools dir, depend mode (gcc emits depfiles via -MMD), and CCACHE_BASEDIR
scoped to the build dir so devices share framework cache entries. Values
the user already set in the environment are respected.
"""
if ccache_path() is None:
return {}
defaults = {
"CCACHE_DIR": str(get_arduino8266_tools_path() / "ccache"),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
+7 -1
View File
@@ -660,11 +660,17 @@ def clean_all(configuration: list[str]):
# that live outside it.
import platformdirs
from esphome.arduino8266.framework import get_arduino8266_tools_path
from esphome.components.nrf52.framework import get_sdk_nrf_tools_path
from esphome.espidf.framework import get_idf_tools_path
cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve()
for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()):
for install_path in (
cache_root,
get_idf_tools_path(),
get_sdk_nrf_tools_path(),
get_arduino8266_tools_path(),
):
if install_path.is_dir():
_LOGGER.info("Deleting %s", install_path)
rmtree(install_path)
+1
View File
@@ -28,6 +28,7 @@ smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
ninja==1.13.0 # native esp8266 arduino toolchain build driver
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
@@ -0,0 +1,431 @@
"""Tests for esphome.arduino8266.framework (downloads and environment)."""
from __future__ import annotations
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import framework
import esphome.config_validation as cv
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _clear_caches(tmp_path: Path) -> None:
framework.ccache_path.cache_clear()
CORE.build_path = tmp_path
def test_framework_package_version() -> None:
assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0"
assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.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()
# A blank prefix must be treated as unset, not as the CWD
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}):
path = framework.get_arduino8266_tools_path()
assert path.name == "arduino8266"
assert path != Path.cwd()
@pytest.mark.parametrize(
("system", "machine", "expected"),
[
("Darwin", "arm64", "darwin_arm64"),
("Darwin", "x86_64", "darwin_x86_64"),
("Windows", "AMD64", "windows_amd64"),
("Windows", "ARM64", "windows_amd64"),
("Windows", "x86", "windows_x86"),
("Linux", "x86_64", "linux_x86_64"),
("Linux", "aarch64", "linux_aarch64"),
("Linux", "i686", "linux_i686"),
("Linux", "armv7l", "linux_armv7l"),
],
)
def test_pio_system(system: str, machine: str, expected: str) -> None:
with (
patch("platform.system", return_value=system),
patch("platform.machine", return_value=machine),
):
assert framework._pio_system() == expected
@pytest.mark.parametrize(
("system", "machine"),
[
("FreeBSD", "amd64"),
("Linux", "ppc64le"),
],
)
def test_pio_system_unsupported_host_raises(system: str, machine: str) -> None:
# Fails at resolution rather than installing a toolchain that can't run
with (
patch("platform.system", return_value=system),
patch("platform.machine", return_value=machine),
pytest.raises(EsphomeError, match="use 'toolchain: platformio'"),
):
framework._pio_system()
def _registry_response(files: list[dict]) -> MagicMock:
resp = MagicMock()
resp.json.return_value = {"versions": [{"name": "1.0.0", "files": files}]}
return resp
def test_registry_download_network_error_is_clean_and_retried() -> None:
"""Registry failures raise EsphomeError after retries, not a traceback."""
import requests
with (
patch("requests.get", side_effect=requests.ConnectionError("boom")) as mock_get,
pytest.raises(EsphomeError, match="Could not query the package registry"),
):
framework._registry_download("pkg", "1.0.0")
assert mock_get.call_count == 3
def test_registry_download_retries_transient_error() -> None:
import requests
resp = _registry_response(
[
{
"system": ["linux_x86_64"],
"download_url": "http://x/linux",
"checksum": {"sha256": "abc123"},
"size": 42,
}
]
)
with (
patch("requests.get", side_effect=[requests.ConnectionError("boom"), resp]),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
assert framework._registry_download("pkg", "1.0.0") == (
"http://x/linux",
"abc123",
42,
)
def test_registry_download_matches_system() -> None:
resp = _registry_response(
[
{"system": ["windows_amd64"], "download_url": "http://x/win"},
{
"system": ["linux_x86_64"],
"download_url": "http://x/linux",
"checksum": {"sha256": "abc123"},
"size": 42,
},
]
)
with (
patch("requests.get", return_value=resp),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
assert framework._registry_download("pkg", "1.0.0") == (
"http://x/linux",
"abc123",
42,
)
def test_registry_download_bare_string_system() -> None:
"""A bare-string system tag is an exact match, not a substring test."""
resp = _registry_response(
[
{"system": "linux_x86", "download_url": "http://x/x86"},
{
"system": "linux_x86_64",
"download_url": "http://x/x86_64",
"checksum": {"sha256": "abc"},
},
]
)
with (
patch("requests.get", return_value=resp),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
assert framework._registry_download("pkg", "1.0.0")[0] == "http://x/x86_64"
def test_registry_download_wildcard_system() -> None:
resp = _registry_response(
[
{
"system": "*",
"download_url": "http://x/any",
"checksum": {"sha256": "abc"},
"size": 7,
}
]
)
with patch("requests.get", return_value=resp):
assert framework._registry_download("pkg", "1.0.0") == (
"http://x/any",
"abc",
7,
)
def test_registry_download_missing_checksum_raises() -> None:
"""An unverifiable archive is refused, never silently extracted."""
resp = _registry_response([{"system": "*", "download_url": "http://x/any"}])
with (
patch("requests.get", return_value=resp),
pytest.raises(EsphomeError, match="no sha256"),
):
framework._registry_download("pkg", "1.0.0")
def test_registry_download_no_system_match() -> None:
resp = _registry_response(
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
)
with (
patch("requests.get", return_value=resp),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
):
framework._registry_download("pkg", "1.0.0")
def test_registry_download_version_not_found() -> None:
resp = _registry_response([])
resp.json.return_value = {"versions": [{"name": "2.0.0", "files": []}]}
with (
patch("requests.get", return_value=resp),
pytest.raises(EsphomeError, match="not found"),
):
framework._registry_download("pkg", "1.0.0")
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
dest.mkdir()
(dest / ".esphome_extracted").touch()
with patch.object(framework, "download_from_mirrors") as mock_download:
framework._install_package("pkg", "1.0.0", dest, [])
mock_download.assert_not_called()
def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"]
with (
patch.object(framework, "download_from_mirrors") as mock_download,
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
# Extraction is expected to create the directory
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir()
framework._install_package("pkg", "1.0.0", dest, mirrors)
assert mock_download.call_args[0][0] is mirrors
assert mock_download.call_args[0][1] == {
"VERSION": "1.0.0",
"SYSTEM": "linux_x86_64",
}
assert (dest / ".esphome_extracted").is_file()
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
"""The registry path downloads with the registry's sha256 and size."""
dest = tmp_path / "pkg"
with (
patch.object(framework, "download_with_resume") as mock_download,
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(
framework,
"_registry_download",
return_value=("http://x/pkg.tar.gz", "abc123", 42),
),
):
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir()
framework._install_package("pkg", "1.0.0", dest, [])
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
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"),
):
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 mock_install.call_count == 2
# The layout checks cover the directories write_project needs, including
# the bundled libraries/ tree
fw_expect = mock_install.call_args_list[0].kwargs["expect"]
assert fw_expect == ("cores/esp8266", "tools/sdk", "libraries")
assert mock_install.call_args_list[1].kwargs["expect"] == ("bin",)
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}):
env = framework.get_build_env(tmp_path)
assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep)
assert env["CCACHE_DIR"] == "x"
def test_ccache_path_disabled_by_env() -> None:
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
assert framework.ccache_path() is None
def test_ccache_path_no_binary(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_CCACHE_ENABLE", raising=False)
with patch("shutil.which", return_value=None):
assert framework.ccache_path() is None
def test_ccache_path_probe_failure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_CCACHE_ENABLE", raising=False)
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("subprocess.run", side_effect=subprocess.SubprocessError),
):
assert framework.ccache_path() is None
def test_ccache_path_ok(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_CCACHE_ENABLE", raising=False)
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("subprocess.run"),
):
assert framework.ccache_path() == "/usr/bin/ccache"
def test_ccache_path_explicit_missing_binary_warns(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", "1")
with patch("shutil.which", return_value=None):
assert framework.ccache_path() is None
assert "no ccache binary is on PATH" in caplog.text
def test_ccache_path_explicit_skips_probe(monkeypatch: pytest.MonkeyPatch) -> None:
"""An explicit opt-in trusts the binary without the runnability probe."""
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", "1")
with (
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("esphome.platformio.toolchain._ccache_runs", side_effect=AssertionError),
):
assert framework.ccache_path() == "/usr/bin/ccache"
def test_ccache_env(tmp_path: Path) -> None:
with patch.object(framework, "ccache_path", return_value=None):
assert framework.ccache_env() == {}
with (
patch.object(framework, "ccache_path", return_value="/usr/bin/ccache"),
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}),
):
env = framework.ccache_env()
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
assert env["CCACHE_DIR"].endswith("ccache")
def test_install_package_validates_expected_layout(tmp_path: Path) -> None:
"""The success marker is only written when the extracted tree is usable."""
dest = tmp_path / "pkg"
with (
patch.object(framework, "download_from_mirrors"),
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
mock_extract.side_effect = lambda *_a, **_kw: (dest / "bin").mkdir(parents=True)
framework._install_package("pkg", "1.0.0", dest, ["http://m"], expect=("bin",))
assert (dest / ".esphome_extracted").is_file()
def test_install_package_unexpected_layout_raises(tmp_path: Path) -> None:
dest = tmp_path / "pkg"
with (
patch.object(framework, "download_from_mirrors"),
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
pytest.raises(EsphomeError, match="without the expected bin"),
):
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir()
framework._install_package("pkg", "1.0.0", dest, ["http://m"], expect=("bin",))
assert not (dest / ".esphome_extracted").exists()
def test_install_package_marker_rechecked_under_lock(tmp_path: Path) -> None:
"""A concurrent install finishing while we wait for the lock is detected."""
dest = tmp_path / "pkg"
marker = dest / ".esphome_extracted"
@contextmanager
def _fake_lock(*_a, **_kw):
dest.mkdir(parents=True, exist_ok=True)
marker.touch()
yield
with (
patch("filelock.FileLock", _fake_lock),
patch.object(framework, "download_from_mirrors") as mock_download,
patch.object(framework, "rmdir") as mock_rmdir,
):
framework._install_package("pkg", "1.0.0", dest, ["http://m"])
mock_download.assert_not_called()
mock_rmdir.assert_not_called()