Files
esphome/tests/unit_tests/test_espidf_framework.py

803 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for esphome.espidf.framework helpers."""
# pylint: disable=protected-access
from contextlib import contextmanager
import io
import json
import logging
from pathlib import Path
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.espidf.framework import (
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
_get_framework_path,
_get_idf_tool_paths,
_get_idf_tools_path,
_get_idf_version,
_get_python_env_path,
_get_python_version,
_parse_git_source,
_patch_tools_json_for_linux_arm64,
_windows_long_paths_enabled,
_write_idf_version_txt,
_write_stamp,
check_esp_idf_install,
get_framework_env,
)
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@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"),
),
# 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) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").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)
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"]
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,
)
# ---------------------------------------------------------------------------
# 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"
@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"),
patch(
"esphome.espidf.framework.download_from_mirrors",
return_value="https://example.com/idf.tar.xz",
) 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._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._write_stamp"),
patch("esphome.espidf.framework._check_stamp", 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, clone=clone
)
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()
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_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)."""
_mark_installed()
with patch("esphome.espidf.framework._check_stamp", return_value=False):
check_esp_idf_install(_IDF_VERSION)
espidf_mocks.extract.assert_not_called() # marker present -> no re-extract
espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt
def test_check_esp_idf_install_check_command_failure_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A failing idf_tools check reinstalls tools (marker present, no re-extract)."""
_mark_installed()
# idf_tools check fails -> install stays True; the later installs succeed.
espidf_mocks.run_ok.side_effect = [False, True, True, True]
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
espidf_mocks.venv.assert_called_once()
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 passes for the framework (no python_version key) and fails
# for the python env (carries python_version), so only the venv rebuilds.
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 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()
# ---------------------------------------------------------------------------
# _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
# ---------------------------------------------------------------------------
# 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"}),
),
):
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=([], {})),
):
env = get_framework_env(tmp_path / "fw")
assert "IDF_PYTHON_ENV_PATH" not in env
assert env["PATH"] # taken from os.environ
# ---------------------------------------------------------------------------
# _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_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 test_get_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)
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