"""Tests for esphome.espidf.framework helpers.""" # pylint: disable=protected-access from contextlib import contextmanager import importlib.util import io import json import logging import os from pathlib import Path import runpy import subprocess import sys import tarfile from types import SimpleNamespace from unittest.mock import patch import pytest from esphome.espidf.framework import ( ESPHOME_STAMP_FILE, STAMP_SCHEMA_VERSION, _ccache_env, _check_esphome_idf_framework_install, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, _get_idf_version, _get_python_env_path, _get_python_version, _parse_git_source, _patch_tools_json_demote_unused_tools, _patch_tools_json_for_linux_arm64, _prefetch_idf_tool_archives, _read_stamp, _stamp_covers, _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, check_esp_idf_install, get_framework_env, get_idf_tools_path, ) from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @pytest.fixture(autouse=True) def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Pin the ESP-IDF install root to a tmp dir for every test. The default location is the OS user cache dir, so without this any test that builds framework paths or pre-creates the framework dir would touch the real ``~/.cache/esphome`` on the developer's machine. Tests that need to exercise the override or default-resolution logic clear/override the env themselves. """ monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install")) @pytest.mark.parametrize( ("source", "expected"), [ # github:// shorthand ( "github://espressif/esp-idf", ("https://github.com/espressif/esp-idf.git", None), ), ( "github://espressif/esp-idf@master", ("https://github.com/espressif/esp-idf.git", "master"), ), ( "github://espressif/esp-idf@release/v6.0", ("https://github.com/espressif/esp-idf.git", "release/v6.0"), ), # explicit https://github.com/...git URL ( "https://github.com/espressif/esp-idf.git", ("https://github.com/espressif/esp-idf.git", None), ), ( "https://github.com/espressif/esp-idf.git@master", ("https://github.com/espressif/esp-idf.git", "master"), ), ( "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), # '#' ref separator (PlatformIO/git-web convention) works on both forms ( "https://github.com/espressif/esp-idf.git#release/v6.1", ("https://github.com/espressif/esp-idf.git", "release/v6.1"), ), ( "github://espressif/esp-idf#release/v6.1", ("https://github.com/espressif/esp-idf.git", "release/v6.1"), ), ( "github://espressif/esp-idf.git#master", ("https://github.com/espressif/esp-idf.git", "master"), ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( "github://espressif/esp-idf.git", ("https://github.com/espressif/esp-idf.git", None), ), ( "github://espressif/esp-idf.git@master", ("https://github.com/espressif/esp-idf.git", "master"), ), ], ) def test_parse_git_source_recognized( source: str, expected: tuple[str, str | None] ) -> None: assert _parse_git_source(source) == expected @pytest.mark.parametrize( "source", [ # archive URLs fall through to the existing download path "https://github.com/espressif/esp-idf/archive/refs/heads/master.zip", "https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip", "https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz", # SSH and other git protocols are intentionally rejected — match # external_components, which only recognizes github:// + structured # dicts for these. "git@github.com:espressif/esp-idf.git", "ssh://git@github.com/espressif/esp-idf.git", "git://github.com/espressif/esp-idf.git", # non-GitHub .git URLs are intentionally rejected for the same reason "https://gitlab.com/foo/bar.git", "https://github.example.com/foo/bar.git", ], ) def test_parse_git_source_rejected(source: str) -> None: assert _parse_git_source(source) is None def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None: """Create the minimum tree _clone_idf_with_submodules sanity-checks for. ``gitmodules=False`` simulates a fork that vendors components in-tree instead of declaring submodules; update_submodules skips the git call when that file is missing. """ (framework_path / "tools").mkdir(parents=True) (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") if gitmodules: (framework_path / ".gitmodules").write_text("# stub\n") def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: framework_path = tmp_path / "idf" framework_path.mkdir() _make_idf_tree(framework_path) with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: _clone_idf_with_submodules( framework_path, "https://github.com/espressif/esp-idf.git", None ) # No ref -> just clone + submodule update, no fetch/reset. calls = [c.args[0] for c in run_git_command_mock.call_args_list] assert calls[0] == [ "git", "clone", "--depth=1", "--", "https://github.com/espressif/esp-idf.git", str(framework_path), ] assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] assert not any(c[1] == "fetch" for c in calls) assert not any(c[1] == "reset" for c in calls) # The clone must retry transient network failures and clean up a # partial destination between attempts clone_kwargs = run_git_command_mock.call_args_list[0].kwargs assert clone_kwargs["network"] is True assert clone_kwargs["retry_cleanup"] == framework_path def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: framework_path = tmp_path / "idf" framework_path.mkdir() _make_idf_tree(framework_path) with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: _clone_idf_with_submodules( framework_path, "https://github.com/espressif/esp-idf.git", "master", ) calls = [c.args[0] for c in run_git_command_mock.call_args_list] # clone, fetch ref, reset hard, submodule update assert calls[0][:2] == ["git", "clone"] assert calls[1] == [ "git", "fetch", "--depth=1", "--", "origin", "master", ] assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] # Clone and fetch talk to the network and must carry the retry flag; # the local reset must not kwargs = [c.kwargs for c in run_git_command_mock.call_args_list] assert kwargs[0]["network"] is True assert kwargs[0]["retry_cleanup"] == framework_path assert kwargs[1]["network"] is True assert "network" not in kwargs[2] def test_clone_idf_with_submodules_raises_when_tree_missing( tmp_path: Path, ) -> None: framework_path = tmp_path / "idf" framework_path.mkdir() # Deliberately do NOT call _make_idf_tree — simulate a clone that # returned 0 but produced no tools/idf_tools.py. with ( patch("esphome.git.run_git_command", return_value=""), pytest.raises(RuntimeError, match="no usable ESP-IDF tree"), ): _clone_idf_with_submodules( framework_path, "https://github.com/espressif/esp-idf.git", None, ) def test_clone_idf_accepts_flattened_fork_without_gitmodules( tmp_path: Path, ) -> None: """A fork that vendors components in-tree instead of as submodules is valid. No .gitmodules means the submodule step is skipped entirely. """ framework_path = tmp_path / "idf" framework_path.mkdir() _make_idf_tree(framework_path, gitmodules=False) with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: _clone_idf_with_submodules( framework_path, "https://github.com/example/flattened-esp-idf.git", None, ) calls = [c.args[0] for c in run_git_command_mock.call_args_list] assert not any(c[1] == "submodule" for c in calls) # --------------------------------------------------------------------------- # Helpers for _tar_extract_all hard-link prefix-stripping tests # --------------------------------------------------------------------------- def _make_tar( members: list[tarfile.TarInfo], file_contents: dict[str, bytes] ) -> io.BytesIO: """Build an in-memory tar archive from a list of TarInfo objects.""" buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tf: for info in members: if info.isreg() and info.name in file_contents: data = file_contents[info.name] info.size = len(data) tf.addfile(info, io.BytesIO(data)) else: tf.addfile(info) buf.seek(0) return buf def _regular(name: str) -> tarfile.TarInfo: info = tarfile.TarInfo(name=name) info.type = tarfile.REGTYPE info.size = 0 info.mode = 0o644 return info def _hardlink(name: str, linkname: str) -> tarfile.TarInfo: info = tarfile.TarInfo(name=name) info.type = tarfile.LNKTYPE info.linkname = linkname info.size = 0 info.mode = 0o644 return info class TestTarExtractHardLinkPrefixStripping: """ Covers the hard-link prefix-stripping block in _tar_extract_all (L528-541). Archive layout used by every test: wrapper/ ← single top-level wrapper dir (stripped) wrapper/target.txt ← regular file; becomes target.txt in dest wrapper/link_good ← hard link to wrapper/target.txt (kept, linkname stripped) wrapper/link_exact_root ← hard link to "wrapper" (skipped – equals strip_root) wrapper/link_exact_prefix ← hard link to "wrapper/" (skipped – equals strip_prefix) wrapper/link_outside ← hard link to "other/target.txt" (skipped – not under prefix) """ WRAPPER = "wrapper" def _build_archive(self) -> io.BytesIO: members = [ _regular(f"{self.WRAPPER}/"), _regular(f"{self.WRAPPER}/target.txt"), _hardlink(f"{self.WRAPPER}/link_good", f"{self.WRAPPER}/target.txt"), _hardlink(f"{self.WRAPPER}/link_exact_root", self.WRAPPER), _hardlink(f"{self.WRAPPER}/link_exact_prefix", f"{self.WRAPPER}/"), _hardlink(f"{self.WRAPPER}/link_outside", "other/target.txt"), ] return _make_tar(members, {f"{self.WRAPPER}/target.txt": b"hello"}) def test_good_hardlink_is_extracted_with_stripped_linkname( self, tmp_path: Path ) -> None: """Hard link whose linkname starts with wrapper/ is extracted and its linkname has the prefix removed so tarfile can resolve the target.""" _tar_extract_all(self._build_archive(), tmp_path) link = tmp_path / "link_good" assert link.exists(), "link_good should have been extracted" assert link.read_bytes() == b"hello" def test_hardlink_equal_to_strip_root_is_skipped(self, tmp_path: Path) -> None: """Hard link whose linkname equals strip_root exactly must be dropped.""" _tar_extract_all(self._build_archive(), tmp_path) assert not (tmp_path / "link_exact_root").exists() def test_hardlink_equal_to_strip_prefix_is_skipped(self, tmp_path: Path) -> None: """Hard link whose linkname equals strip_prefix (strip_root + '/') must be dropped.""" _tar_extract_all(self._build_archive(), tmp_path) assert not (tmp_path / "link_exact_prefix").exists() def test_hardlink_outside_prefix_is_skipped(self, tmp_path: Path) -> None: """Hard link whose linkname does not start with wrapper/ must be dropped.""" _tar_extract_all(self._build_archive(), tmp_path) assert not (tmp_path / "link_outside").exists() def test_regular_file_and_no_spurious_files(self, tmp_path: Path) -> None: """Sanity check: target.txt is extracted and no unexpected files appear.""" _tar_extract_all(self._build_archive(), tmp_path) assert (tmp_path / "target.txt").read_bytes() == b"hello" extracted = {p.name for p in tmp_path.iterdir()} assert extracted == {"target.txt", "link_good"} _IDF_VERSION = "5.1.2" def _fake_download_from_mirrors( mirrors: list[str], substitutions: dict[str, str], target: object, **kwargs: object, ) -> str: """Stand-in for download_from_mirrors that creates path targets, since the framework code opens the downloaded tarball afterwards.""" if isinstance(target, (str, os.PathLike)): path = Path(target) path.parent.mkdir(parents=True, exist_ok=True) path.touch() return "https://example.com/idf.tar.xz" @pytest.fixture def espidf_mocks(setup_core: Path): """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" # archive_extract_all is mocked, so pre-create the framework dir that the # extracted-marker touch writes into. _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) with ( patch("esphome.espidf.framework.rmdir") as rmdir_mock, patch( "esphome.espidf.framework.download_from_mirrors", side_effect=_fake_download_from_mirrors, ) as download, patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, patch( "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"), patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), patch("esphome.espidf.framework._stamp_covers", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( download=download, extract=extract, venv=venv, run_ok=run_ok, tool_paths=tool_paths, clone=clone, rmdir=rmdir_mock, ) def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: """A forced install drives download/extract, venv creation, and pip installs.""" framework_path, python_env_path = check_esp_idf_install(_IDF_VERSION, force=True) assert framework_path == _get_framework_path(_IDF_VERSION) assert python_env_path == _get_python_env_path(_IDF_VERSION) # framework tarball + python-env constraints file are both downloaded assert espidf_mocks.download.call_count == 2 espidf_mocks.extract.assert_called_once() espidf_mocks.venv.assert_called_once() espidf_mocks.clone.assert_not_called() # the tool download cache (/dist) is pruned after install espidf_mocks.rmdir.assert_any_call( get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" ) def test_check_esp_idf_install_dist_prune_failure_ignored( espidf_mocks: SimpleNamespace, ) -> None: """A failure to prune the tool download cache must not fail the install.""" tools_dist = get_idf_tools_path() / "dist" def rmdir_side_effect(directory: Path, msg: str | None = None) -> None: if directory == tools_dist: raise RuntimeError("cannot remove dist") espidf_mocks.rmdir.side_effect = rmdir_side_effect # install still succeeds despite the failed prune framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True) assert framework_path == _get_framework_path(_IDF_VERSION) def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: """A git source_url clones instead of downloading; explicit tools skip discovery.""" check_esp_idf_install( _IDF_VERSION, force=True, source_url="https://github.com/espressif/esp-idf.git", tools=["xtensa-esp-elf"], ) espidf_mocks.clone.assert_called_once() # framework is cloned, so only the python-env constraints file is downloaded assert espidf_mocks.download.call_count == 1 def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) -> None: """Marker + matching stamps + existing python env → nothing is re-installed.""" framework_path = _get_framework_path(_IDF_VERSION) (framework_path / ".esphome_extracted").touch() python_env_path = _get_python_env_path(_IDF_VERSION) env_python = get_python_env_executable_path(python_env_path, "python") env_python.parent.mkdir(parents=True, exist_ok=True) env_python.touch() check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() espidf_mocks.venv.assert_not_called() def test_corrupt_tarball_removed_when_extraction_fails( espidf_mocks: SimpleNamespace, ) -> None: """A tarball that fails to extract (e.g. torn by an unclean shutdown) is deleted so the next run re-downloads instead of failing forever.""" espidf_mocks.extract.side_effect = RuntimeError("xz: unexpected end of input") tarball = get_idf_tools_path() / "dist" / f"esp-idf-{_IDF_VERSION}.tar.xz" with pytest.raises(RuntimeError, match="unexpected end of input"): check_esp_idf_install(_IDF_VERSION, force=True) assert not tarball.exists() def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: """A failing idf_tools install raises.""" espidf_mocks.run_ok.side_effect = [False] with pytest.raises(RuntimeError, match="framework installation failure"): check_esp_idf_install(_IDF_VERSION, force=True) def test_check_esp_idf_install_pip_upgrade_failure( espidf_mocks: SimpleNamespace, ) -> None: """A failing pip upgrade in the python env raises (framework install ok).""" espidf_mocks.run_ok.side_effect = [True, False] with pytest.raises(RuntimeError, match="Python environment packages failure"): check_esp_idf_install(_IDF_VERSION, force=True) def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) -> None: """A failing feature requirements install raises.""" espidf_mocks.run_ok.side_effect = [True, True, False] with pytest.raises(RuntimeError, match="Python dependencies for"): check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) def _mark_installed() -> None: """Create the extracted marker and python-env interpreter so the install check takes the already-installed path rather than force-installing.""" (_get_framework_path(_IDF_VERSION) / ".esphome_extracted").touch() env_python = get_python_env_executable_path( _get_python_env_path(_IDF_VERSION), "python" ) env_python.parent.mkdir(parents=True, exist_ok=True) env_python.touch() def test_check_esp_idf_install_stamp_mismatch_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: """A stamp mismatch reinstalls tools (marker present, so no re-extract). The python env is left alone: it depends on the framework version and features, not on which toolchains are installed. """ _mark_installed() with patch("esphome.espidf.framework._stamp_covers", return_value=False): check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() # marker present -> no re-extract espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() # Managed tool resolution fails -> install stays True; the later installs succeed. espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_unknown_python_version_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: """An undeterminable python version rebuilds the venv (framework stamp still ok).""" _mark_installed() with patch("esphome.espidf.framework._get_python_version", return_value=None): check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() # framework stamp matched espidf_mocks.venv.assert_called_once() # python env rebuilt def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks: SimpleNamespace, ) -> None: """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" # _check_stamp only guards the python env now (the framework uses # _stamp_covers, patched True by the fixture); failing it rebuilds the venv. def stamp_ok(_stamp_file, info: dict) -> bool: return "python_version" not in info _mark_installed() with patch("esphome.espidf.framework._check_stamp", side_effect=stamp_ok): check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() espidf_mocks.venv.assert_called_once() def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict: return { "schema_version": STAMP_SCHEMA_VERSION, "targets": targets, "tools": tools or ["required"], } @pytest.mark.parametrize( ("stored", "targets", "expected"), [ # a stored "all" covers any target (_requested_stamp(["all"]), ["esp32"], True), # exact match and superset both cover (_requested_stamp(["esp32"]), ["esp32"], True), (_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True), # a new target is not covered (_requested_stamp(["esp32"]), ["esp32c3"], False), # tools and schema_version must match exactly (_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False), (_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False), # an unknown extra field participates in invalidation by default (_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False), # missing/corrupt stamps never cover (None, ["esp32"], False), ( {"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]}, ["esp32"], False, ), ], ) def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None: assert _stamp_covers(stored, _requested_stamp(targets)) is expected @contextmanager def _framework_install_patches(): """Patches for calling _check_esphome_idf_framework_install directly with real stamp files (unlike espidf_mocks, which stubs the stamp layer).""" with ( patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), patch("esphome.espidf.framework.get_system_python_path", return_value="python"), patch("esphome.espidf.framework.rmdir"), ): yield run_ok def _extracted_framework_with_stamp(stamp: dict) -> Path: framework_path = _get_framework_path(_IDF_VERSION) framework_path.mkdir(parents=True, exist_ok=True) (framework_path / ".esphome_extracted").touch() _write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp) return framework_path def test_framework_install_target_subset_skips_install() -> None: """A stamp holding a superset of the requested targets skips the installer.""" framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"])) with _framework_install_patches() as run_ok: _, fresh_extract = _check_esphome_idf_framework_install( _IDF_VERSION, ["esp32"], ["required"] ) run_ok.assert_not_called() assert fresh_extract is False # the stamp is untouched stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) assert stamp["targets"] == ["all"] def test_framework_install_new_target_installs_and_merges_stamp() -> None: """A new target runs the installer for just that target and the stamp records the union of everything installed so far.""" framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) with _framework_install_patches() as run_ok: _, fresh_extract = _check_esphome_idf_framework_install( _IDF_VERSION, ["esp32c3"], ["required"] ) assert fresh_extract is False assert "--targets=esp32c3" in run_ok.call_args[0][0] stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) assert stamp["targets"] == ["esp32", "esp32c3"] def test_check_esp_idf_install_env_targets_override_wins( espidf_mocks: SimpleNamespace, ) -> None: """An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets.""" with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True): check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] assert "--targets=all" in install_cmd def test_check_esp_idf_install_uses_requested_targets( espidf_mocks: SimpleNamespace, ) -> None: """Without the env override, the caller's per-variant targets are installed.""" check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] assert "--targets=esp32" in install_cmd def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None: """Requesting "all" over a per-variant stamp merges and collapses to ["all"], not ["all", "esp32"], so the stamp shape stays canonical.""" framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) with _framework_install_patches() as run_ok: _check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"]) run_ok.assert_called_once() stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) assert stamp["targets"] == ["all"] def test_framework_install_tools_change_resets_stamp_targets() -> None: """A reinstall triggered by a tools change must not carry the old stamp's targets forward: the installer only ran for this build's targets, so a merged stamp would let other variants skip the reinstall they need.""" framework_path = _extracted_framework_with_stamp( _requested_stamp(["all"], tools=["cmake", "required"]) ) with _framework_install_patches() as run_ok: _check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"]) run_ok.assert_called_once() stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) assert stamp["targets"] == ["esp32"] assert stamp["tools"] == ["required"] @pytest.mark.parametrize( ("lib", "expect_hint"), [ (None, True), ("libusb-1.0.so.0", False), ], ) def test_check_esp_idf_install_failure_libusb_hint( espidf_mocks: SimpleNamespace, caplog: pytest.LogCaptureFixture, lib: str | None, expect_hint: bool, ) -> None: """A failed tools install only shows the libusb hint when libusb-1.0 is actually missing.""" espidf_mocks.run_ok.return_value = False # Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe # everywhere (unlike faking Windows, which pulls in winreg on other hosts) with ( patch("esphome.espidf.framework.find_library", return_value=lib), patch("esphome.espidf.framework.platform.system", return_value="Linux"), caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"), pytest.raises(RuntimeError, match="framework installation failure"), ): check_esp_idf_install(_IDF_VERSION, force=True) assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint def test_check_esp_idf_install_unparseable_version( espidf_mocks: SimpleNamespace, ) -> None: """A non-semver version skips the MAJOR/MINOR substitutions without erroring.""" bad_version = "main" _get_framework_path(bad_version).mkdir(parents=True, exist_ok=True) check_esp_idf_install(bad_version, force=True) espidf_mocks.extract.assert_called_once() @pytest.mark.parametrize( ("version", "short_version"), [ ("6.0.0", "6.0"), ("6.0.0-rc1", "6.0-rc1"), ("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases ], ) def test_check_esp_idf_install_short_version_substitution( espidf_mocks: SimpleNamespace, version: str, short_version: str | None ) -> None: """SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror template is never tried for versions whose tag cannot exist.""" _get_framework_path(version).mkdir(parents=True, exist_ok=True) check_esp_idf_install(version, force=True) # First call downloads the framework archive; a later call fetches the # constraints file with its own substitutions. substitutions = espidf_mocks.download.call_args_list[0][0][1] assert substitutions.get("SHORT_VERSION") == short_version assert substitutions["VERSION"] == version # --------------------------------------------------------------------------- # _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) # --------------------------------------------------------------------------- def _write_tools_json(framework_path: Path, data: dict) -> Path: tools_dir = framework_path / "tools" tools_dir.mkdir(parents=True, exist_ok=True) tools_json = tools_dir / "tools.json" tools_json.write_text(json.dumps(data), encoding="utf-8") return tools_json def test_patch_tools_json_non_aarch64_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, {"tools": [{"name": "ninja", "versions": [{"name": "1.12.1"}]}]} ) before = tools_json.read_text(encoding="utf-8") with patch("esphome.espidf.framework.platform.machine", return_value="x86_64"): _patch_tools_json_for_linux_arm64(tmp_path) assert tools_json.read_text(encoding="utf-8") == before def test_patch_tools_json_missing_file_is_noop(tmp_path: Path) -> None: with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): _patch_tools_json_for_linux_arm64(tmp_path) # no tools/tools.json present def test_patch_tools_json_corrupt_file_warns_and_skips(tmp_path: Path) -> None: (tmp_path / "tools").mkdir() (tmp_path / "tools" / "tools.json").write_text("{ not json", encoding="utf-8") with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): _patch_tools_json_for_linux_arm64(tmp_path) # JSONDecodeError -> skip def test_patch_tools_json_injects_ninja_arm64(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "ninja", "versions": [{"name": "1.12.1"}]}, {"name": "cmake", "versions": [{"name": "3.24.0"}]}, ] }, ) with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): _patch_tools_json_for_linux_arm64(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) ninja = next(t for t in data["tools"] if t["name"] == "ninja") assert "linux-arm64" in ninja["versions"][0] assert ninja["versions"][0]["linux-arm64"]["size"] == 121787 def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ { "name": "ninja", "versions": [{"name": "1.12.1", "linux-arm64": {"url": "x"}}], } ] }, ) before = tools_json.read_text(encoding="utf-8") with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): _patch_tools_json_for_linux_arm64(tmp_path) assert tools_json.read_text(encoding="utf-8") == before # --------------------------------------------------------------------------- # _prefetch_idf_tool_archives # --------------------------------------------------------------------------- _PREFETCH_JSON = json.dumps( [ { "name": "cmake@3.30.2", "url": "https://example.com/cmake.tar.gz", "size": 123, "sha256": "ab" * 32, "dest": "cmake-3.30.2.tar.gz", }, { "name": "ninja@1.12.1", "url": "https://example.com/ninja.zip", "size": 45, "sha256": "cd" * 32, "dest": "ninja.zip", }, ] ) def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None: with ( patch( "esphome.espidf.framework.run_command", return_value=(True, _PREFETCH_JSON, ""), ), patch("esphome.espidf.framework.download_with_resume") as download, patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) dist = get_idf_tools_path() / "dist" assert download.call_count == 2 assert download.call_args_list[0][0] == ( "https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz", ) assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123} def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None: dist = get_idf_tools_path() / "dist" dist.mkdir(parents=True) (dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached") with ( patch( "esphome.espidf.framework.run_command", return_value=(True, _PREFETCH_JSON, ""), ), patch("esphome.espidf.framework.download_with_resume") as download, patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) # only the missing archive is downloaded assert download.call_count == 1 assert download.call_args[0][1] == dist / "ninja.zip" @pytest.mark.parametrize( ("run_result", "download_error", "expected_log"), [ ((False, "", "script exploded"), None, "tool downloads"), # script failure ((True, "{ not json", ""), None, "prefetch failed"), # unparsable output ( (True, _PREFETCH_JSON, ""), OSError("network down"), "Could not prefetch", ), # download failure ], ) def test_prefetch_failures_never_raise( tmp_path: Path, caplog: pytest.LogCaptureFixture, run_result: tuple[bool, str, str], download_error: Exception | None, expected_log: str, ) -> None: """The prefetch is best-effort; idf_tools downloads whatever is missing.""" with ( patch("esphome.espidf.framework.run_command", return_value=run_result), patch( "esphome.espidf.framework.download_with_resume", side_effect=download_error, ), patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) assert expected_log in caplog.text def test_prefetch_one_failed_archive_does_not_stop_the_rest( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A single archive failing its download must not abort the prefetch of the remaining archives.""" with ( patch( "esphome.espidf.framework.run_command", return_value=(True, _PREFETCH_JSON, ""), ), patch( "esphome.espidf.framework.download_with_resume", side_effect=[OSError("network down"), None], ) as download, patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) assert download.call_count == 2 assert "Could not prefetch cmake@3.30.2" in caplog.text def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None: with ( patch( "esphome.espidf.framework.run_command", return_value=(True, "[]", "") ) as run, patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): _prefetch_idf_tool_archives( tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "/x"} ) cmd = run.call_args[0][0] assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"] assert cmd[1].endswith("get_tool_downloads.py") # the script inherits the caller's env plus the framework tools PYTHONPATH env = run.call_args[1]["env"] assert env["IDF_TOOLS_PATH"] == "/x" assert env["PYTHONPATH"] == str(tmp_path / "tools") def test_framework_install_prefetches_before_installer( espidf_mocks: SimpleNamespace, ) -> None: """The prefetch runs before idf_tools.py install so the installer finds the archives already in dist/.""" calls: list[str] = [] with ( patch( "esphome.espidf.framework._prefetch_idf_tool_archives", side_effect=lambda *a, **k: calls.append("prefetch"), ), ): espidf_mocks.run_ok.side_effect = lambda *a, **k: ( calls.append("install") or True ) check_esp_idf_install(_IDF_VERSION, force=True) assert calls.index("prefetch") < calls.index("install") # --------------------------------------------------------------------------- # get_tool_downloads.py (against the stub idf_tools module in fixtures/) # --------------------------------------------------------------------------- _IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub" def _run_downloads_script( tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: """Run the real get_tool_downloads.py against the stub idf_tools module.""" script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" env = os.environ | { "PYTHONPATH": str(_IDF_TOOLS_STUB_DIR), "IDF_TOOLS_PATH": str(tmp_path / "tp"), } if env_extra: env |= env_extra return subprocess.run( [sys.executable, str(script), str(tmp_path / "fw"), *args], capture_output=True, text=True, env=env, check=False, ) def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None: """Installed versions are skipped, tools that fail their binary check are still listed, rename_dist decides the dist filename, and idf_tools' stdout chatter stays off the JSON channel.""" result = _run_downloads_script(tmp_path, "esp32", "required") assert result.returncode == 0, result.stderr downloads = {d["name"]: d for d in json.loads(result.stdout)} # installed-tool@1.0 is already installed and must not be listed assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} assert downloads["cmake@3.30.2"]["dest"] == "cmake.tar.gz" assert downloads["cmake@3.30.2"]["size"] == 11 assert downloads["cmake@3.30.2"]["sha256"] == "aa" # rename_dist overrides the URL basename assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" # the stub prints informational lines; they must be on stderr assert "Changed download URL" in result.stderr def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None: result = _run_downloads_script( tmp_path, "esp32", "required", env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"}, ) assert result.returncode == 0, result.stderr downloads = json.loads(result.stdout) assert all(d["url"].startswith("https://mirror.test/") for d in downloads) def _run_downloads_inprocess( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], *args: str, ) -> list[dict]: """Execute get_tool_downloads.py in-process against the stub idf_tools. Unlike the subprocess variant this runs under coverage, exercising the script's own lines. """ spec = importlib.util.spec_from_file_location( "idf_tools", _IDF_TOOLS_STUB_DIR / "idf_tools.py" ) stub = importlib.util.module_from_spec(spec) spec.loader.exec_module(stub) monkeypatch.setitem(sys.modules, "idf_tools", stub) monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp")) script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py" monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args]) runpy.run_path(str(script)) return json.loads(capsys.readouterr().out) def test_get_tool_downloads_inprocess_full_flow( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """In-process run covering the whole script: required expansion, installed/broken tools, rename_dist, and version pinning via tool@version.""" downloads = { d["name"]: d for d in _run_downloads_inprocess( tmp_path, monkeypatch, capsys, "esp32", "required" ) } assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"} assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip" assert downloads["cmake@3.30.2"]["url"] == "https://gh.test/cmake.tar.gz" def test_get_tool_downloads_inprocess_explicit_tool_specs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Explicit tool names and tool@version specs resolve; unknown tools and unknown versions are skipped.""" downloads = _run_downloads_inprocess( tmp_path, monkeypatch, capsys, "esp32", "cmake@3.30.2", "no-such-tool", "cmake@9.9.9", ) assert [d["name"] for d in downloads] == ["cmake@3.30.2"] # --------------------------------------------------------------------------- # _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional) # --------------------------------------------------------------------------- def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "openocd-esp32", "install": "always"}, {"name": "xtensa-esp-elf-gdb", "install": "always"}, {"name": "riscv32-esp-elf-gdb", "install": "always"}, {"name": "esp32ulp-elf", "install": "always"}, {"name": "xtensa-esp-elf", "install": "always"}, {"name": "esp-rom-elfs", "install": "always"}, ] }, ) _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) install_types = {t["name"]: t["install"] for t in data["tools"]} assert install_types == { "openocd-esp32": "on_request", "xtensa-esp-elf-gdb": "on_request", "riscv32-esp-elf-gdb": "on_request", "esp32ulp-elf": "on_request", # the compiler toolchain and ROM ELFs stay required "xtensa-esp-elf": "always", "esp-rom-elfs": "always", } def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None: """riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome never builds) but keeps its RISC-V targets; other tools are untouched.""" tools_json = _write_tools_json( tmp_path, { "tools": [ { "name": "riscv32-esp-elf", "install": "always", "supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"], }, { "name": "xtensa-esp-elf", "install": "always", "supported_targets": ["esp32", "esp32s2", "esp32s3"], }, ] }, ) _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf") assert riscv["supported_targets"] == ["esp32c3", "esp32p4"] assert riscv["install"] == "always" assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"] def test_demote_unused_tools_bad_supported_targets_type_still_demotes( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A non-list supported_targets on riscv32-esp-elf must not abort the other demotions; the targets patch is best-effort and logs the skip so a silently resumed riscv download is diagnosable.""" tools_json = _write_tools_json( tmp_path, { "tools": [ { "name": "riscv32-esp-elf", "install": "always", "supported_targets": None, }, {"name": "openocd-esp32", "install": "always"}, ] }, ) with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") assert openocd["install"] == "on_request" assert riscv["supported_targets"] is None assert "Unexpected supported_targets" in caplog.text def test_patch_tools_json_unexpected_structure_warns_and_skips( tmp_path: Path, ) -> None: """Valid JSON with an unexpected shape must skip the patch, not raise.""" tools_dir = tmp_path / "tools" tools_dir.mkdir() tools_json = tools_dir / "tools.json" tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") before = tools_json.read_text(encoding="utf-8") _patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip assert tools_json.read_text(encoding="utf-8") == before def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "openocd-esp32", "install": "on_request"}, {"name": "xtensa-esp-elf-gdb", "install": "on_request"}, {"name": "riscv32-esp-elf-gdb", "install": "on_request"}, {"name": "esp32ulp-elf", "install": "on_request"}, { "name": "riscv32-esp-elf", "install": "always", "supported_targets": ["esp32c3", "esp32p4"], }, ] }, ) before = tools_json.read_text(encoding="utf-8") _patch_tools_json_demote_unused_tools(tmp_path) assert tools_json.read_text(encoding="utf-8") == before # --------------------------------------------------------------------------- # Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env # --------------------------------------------------------------------------- def test_get_idf_version_parses_stdout(tmp_path: Path) -> None: with patch( "esphome.espidf.framework.run_command", return_value=(True, "5.1.2\n", "") ): assert _get_idf_version(tmp_path) == "5.1.2" def test_get_idf_version_raises_on_failure(tmp_path: Path) -> None: with ( patch("esphome.espidf.framework.run_command", return_value=(False, "", "boom")), pytest.raises(RuntimeError, match="Can't get ESP-IDF version"), ): _get_idf_version(tmp_path) def test_get_idf_tool_paths_parses_json(tmp_path: Path) -> None: payload = json.dumps({"paths_to_export": ["/a", "/b"], "export_vars": {"X": "1"}}) with patch( "esphome.espidf.framework.run_command", return_value=(True, payload, "") ): paths, export_vars = _get_idf_tool_paths(tmp_path) assert paths == ["/a", "/b"] assert export_vars == {"X": "1"} def test_get_idf_tool_paths_raises_on_bad_json(tmp_path: Path) -> None: with ( patch( "esphome.espidf.framework.run_command", return_value=(True, "not json", "") ), pytest.raises(RuntimeError, match="Can't extract ESP-IDF tool paths"), ): _get_idf_tool_paths(tmp_path) def test_get_idf_tool_paths_raises_on_failure(tmp_path: Path) -> None: with ( patch("esphome.espidf.framework.run_command", return_value=(False, "", "err")), pytest.raises(RuntimeError, match="Can't get ESP-IDF tool paths"), ): _get_idf_tool_paths(tmp_path) def test_get_python_version_parses_stdout(tmp_path: Path) -> None: with patch( "esphome.espidf.framework.run_command", return_value=(True, "3.11.0\n", "") ): assert _get_python_version(tmp_path / "python") == "3.11.0" def test_get_python_version_returns_falsy_on_failure(tmp_path: Path) -> None: with patch("esphome.espidf.framework.run_command", return_value=(False, "", "")): # non-throwing failure returns the (empty) stdout as-is assert not _get_python_version(tmp_path / "python") def test_get_python_version_raises_when_requested(tmp_path: Path) -> None: with ( patch("esphome.espidf.framework.run_command", return_value=(False, "", "")), pytest.raises(RuntimeError, match="Can't get Python version"), ): _get_python_version(tmp_path / "python", throw_exception=True) def test_write_stamp_writes_json(tmp_path: Path) -> None: stamp = tmp_path / "stamp.json" _write_stamp(stamp, {"a": "1", "b": "2"}) assert json.loads(stamp.read_text(encoding="utf-8")) == {"a": "1", "b": "2"} def test_get_framework_env_with_python_env(tmp_path: Path) -> None: with ( patch( "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch( "esphome.espidf.framework._get_idf_tool_paths", return_value=(["/tool/bin"], {"IDF_X": "1"}), ), # ccache env is covered separately; keep this test host-independent. patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env( tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} ) assert env["IDF_PATH"] == str(tmp_path / "fw") assert env["ESP_IDF_VERSION"] == "5.1.2" assert env["IDF_X"] == "1" assert env["IDF_PYTHON_ENV_PATH"] == str(tmp_path / "penv") assert "/tool/bin" in env["PATH"] def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: with ( patch( "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), # ccache env is covered separately; keep this test host-independent. patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env(tmp_path / "fw") assert "IDF_PYTHON_ENV_PATH" not in env assert env["PATH"] # taken from os.environ # --------------------------------------------------------------------------- # _ccache_env # --------------------------------------------------------------------------- def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): return ( patch("esphome.espidf.framework.shutil.which", return_value=which), patch( "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch( "esphome.espidf.framework.CORE", SimpleNamespace(build_path=build_path), ), ) def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") with patch.dict("os.environ", {}, clear=True), p1, p2, p3: env = _ccache_env() assert env["IDF_CCACHE_ENABLE"] == "1" assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") assert env["CCACHE_NOHASHDIR"] == "true" assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: # build_path is None here too: a disabled cache must not require it. p1, p2, p3 = _ccache_patches(tmp_path, None, None) with patch.dict("os.environ", {}, clear=True), p1, p2, p3: assert _ccache_env() == {} def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: # Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and # short-circuits before build_path is needed. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: assert _ccache_env() == {} def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's # already in the environment, so it isn't re-emitted, but the rest is. p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: env = _ccache_env() assert "IDF_CCACHE_ENABLE" not in env assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") assert env["CCACHE_DEPEND"] == "1" def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: # User-set CCACHE_* values must not be clobbered; unset ones still default. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"} with patch.dict("os.environ", user_env, clear=True), p1, p2, p3: env = _ccache_env() assert "CCACHE_DIR" not in env assert "CCACHE_MAXSIZE" not in env assert env["IDF_CCACHE_ENABLE"] == "1" assert env["CCACHE_DEPEND"] == "1" def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: # Enabled but no build_path means the IDF env was built too early -- fail # loudly instead of silently dropping CCACHE_BASEDIR. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) with ( patch.dict("os.environ", {}, clear=True), p1, p2, p3, pytest.raises(ValueError, match="build_path"), ): _ccache_env() # --------------------------------------------------------------------------- # _check_stamp / _write_idf_version_txt / get_idf_tools_path # --------------------------------------------------------------------------- def test_check_stamp_matches(tmp_path: Path) -> None: f = tmp_path / "s.json" f.write_text(json.dumps({"a": "1"}), encoding="utf-8") assert _check_stamp(f, {"a": "1"}) is True def test_check_stamp_mismatch(tmp_path: Path) -> None: f = tmp_path / "s.json" f.write_text(json.dumps({"a": "1"}), encoding="utf-8") assert _check_stamp(f, {"a": "2"}) is False def test_check_stamp_missing_file(tmp_path: Path) -> None: assert _check_stamp(tmp_path / "nope.json", {"a": "1"}) is False def test_check_stamp_corrupt_file(tmp_path: Path) -> None: f = tmp_path / "s.json" f.write_text("{ not json", encoding="utf-8") assert _check_stamp(f, {"a": "1"}) is False def test_read_stamp_corrupt_file_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # A corrupt stamp forces a full reinstall on every build, so it warns # where the normal missing-file case stays silent. f = tmp_path / "s.json" f.write_text("{ not json", encoding="utf-8") with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): assert _read_stamp(f) is None assert "Ignoring corrupt stamp file" in caplog.text def test_read_stamp_unreadable_file_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # An I/O fault (permissions, disk error) is distinguished from a simply # missing stamp with a warning before falling back to reinstall. f = tmp_path / "s.json" f.write_text(json.dumps({"a": "1"}), encoding="utf-8") with ( patch.object(Path, "open", side_effect=PermissionError("denied")), caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"), ): assert _read_stamp(f) is None assert "Could not read stamp file" in caplog.text def test_read_stamp_non_dict_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # Well-formed JSON that is not an object is a fault, not a first install; # it must leave a trace before forcing reinstalls. f = tmp_path / "s.json" f.write_text("null", encoding="utf-8") with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): assert _read_stamp(f) is None assert "unexpected type NoneType" in caplog.text def test_read_stamp_missing_file_is_silent( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # Missing stamps are the normal first-install case and must not log. with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"): assert _read_stamp(tmp_path / "nope.json") is None assert "stamp file" not in caplog.text def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: _write_idf_version_txt(tmp_path, "5.1.2") assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: (tmp_path / "version.txt").write_text("existing\n", encoding="utf-8") _write_idf_version_txt(tmp_path, "5.1.2") assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" def testget_idf_tools_path_env_override(tmp_path: Path) -> None: override = str(tmp_path / "custom-idf") with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): assert get_idf_tools_path() == Path(override) @pytest.mark.parametrize("value", ["", " "]) def testget_idf_tools_path_blank_env_falls_back_to_default( value: str, monkeypatch: pytest.MonkeyPatch ) -> None: """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. Path("") would resolve to the working directory, which clean-all could then delete by accident. """ import platformdirs monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value) expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() assert get_idf_tools_path() == expected def testget_idf_tools_path_default_uses_user_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: """Without the env override the install root is the machine-global OS user cache dir, not the per-config ``/idf``.""" import platformdirs monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False) expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() assert get_idf_tools_path() == expected def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised _write_idf_version_txt(tmp_path, "5.1.2") def _fake_winreg( query_result: int | None = None, query_error: OSError | None = None ) -> SimpleNamespace: """Build a minimal winreg stand-in (the real module is Windows-only).""" @contextmanager def open_key(root, path): yield "hkey" def query_value_ex(key, name): if query_error is not None: raise query_error return query_result, 4 # (value, REG_DWORD) return SimpleNamespace( HKEY_LOCAL_MACHINE=object(), OpenKey=open_key, QueryValueEx=query_value_ex, ) @pytest.mark.parametrize(("reg_value", "expected"), [(1, True), (0, False)]) def test_windows_long_paths_enabled_reads_registry( reg_value: int, expected: bool ) -> None: with patch.dict(sys.modules, {"winreg": _fake_winreg(query_result=reg_value)}): assert _windows_long_paths_enabled() is expected def test_windows_long_paths_enabled_missing_value() -> None: """A missing registry value (FileNotFoundError is an OSError) reads as disabled.""" fake = _fake_winreg(query_error=FileNotFoundError("no such value")) with patch.dict(sys.modules, {"winreg": fake}): assert _windows_long_paths_enabled() is False # 8 chars -> projected well under the 260 limit even with the ~245-char reserve _SHORT_IDF_PATH = "C:\\e\\idf" # 25 chars -> projected over the limit _LONG_IDF_PATH = "C:\\Users\\bob\\.esphome\\idf" def test_check_windows_path_length_noop_off_windows( caplog: pytest.LogCaptureFixture, ) -> None: """Off Windows the check returns before touching the registry or the path.""" with ( patch("esphome.espidf.framework.platform.system", return_value="Linux"), patch( "esphome.espidf.framework._windows_long_paths_enabled" ) as long_paths_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() long_paths_mock.assert_not_called() assert not caplog.records def test_check_windows_path_length_noop_when_long_paths_enabled( caplog: pytest.LogCaptureFixture, ) -> None: with ( patch("esphome.espidf.framework.platform.system", return_value="Windows"), patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=True ), patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() get_path_mock.assert_not_called() assert not caplog.records def test_check_windows_path_length_short_path_silent( caplog: pytest.LogCaptureFixture, ) -> None: with ( patch("esphome.espidf.framework.platform.system", return_value="Windows"), patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( "esphome.espidf.framework.get_idf_tools_path", return_value=_SHORT_IDF_PATH, ), caplog.at_level(logging.WARNING), ): _check_windows_path_length() assert not caplog.records def test_check_windows_path_length_long_path_warns( caplog: pytest.LogCaptureFixture, ) -> None: with ( patch("esphome.espidf.framework.platform.system", return_value="Windows"), patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( "esphome.espidf.framework.get_idf_tools_path", return_value=_LONG_IDF_PATH, ), caplog.at_level(logging.WARNING), ): _check_windows_path_length() assert len(caplog.records) == 1 message = caplog.records[0].getMessage() assert _LONG_IDF_PATH in message assert "long path support" in message # The install is global now; the remedy is the prefix env, not moving the project. assert "ESPHOME_ESP_IDF_PREFIX" in message