Merge branch 'dev' into platformio-prefetch-git-clones

This commit is contained in:
J. Nick Koston
2026-08-28 14:26:22 -05:00
committed by GitHub
69 changed files with 7886 additions and 9782 deletions
@@ -0,0 +1,246 @@
"""Tests for the ninja build-tool helper script."""
from __future__ import annotations
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_gen import build_tool
def test_ar_removes_stale_archive(tmp_path: Path) -> None:
archive = tmp_path / "lib.a"
archive.write_text("stale")
rsp = tmp_path / "lib.a.rsp"
rsp.write_text("a.o\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
),
patch.object(
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert build_tool.main() == 0
assert not archive.exists()
# The rspfile is expanded by the shim (GNU ar would escape backslashes)
assert mock_run.call_args[0][0] == ["ar-bin", "rcs", str(archive), "a.o"]
def test_copy(tmp_path: Path) -> None:
src = tmp_path / "firmware.bin"
src.write_text("data")
dst = tmp_path / "firmware.factory.bin"
with patch.object(
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(dst)]
):
assert build_tool.main() == 0
assert dst.read_text() == "data"
def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
with patch.object(build_tool.sys, "argv", ["build_tool", "bogus"]):
assert build_tool.main() == 1
assert "unknown build_tool mode" in capsys.readouterr().err
def test_runs_as_script(tmp_path: Path) -> None:
"""The ninja rules invoke the file as a plain script."""
src = tmp_path / "a.bin"
src.write_text("x")
dst = tmp_path / "b.bin"
result = subprocess.run(
[sys.executable, build_tool.__file__, "copy", str(src), str(dst)],
check=False,
)
assert result.returncode == 0
assert dst.read_text() == "x"
def test_ar_expands_rspfile_without_escaping(tmp_path) -> None:
"""Backslash paths survive: the shim expands the rspfile itself instead
of letting GNU ar treat backslashes as escapes."""
rsp = tmp_path / "objs.rsp"
rsp.write_text("obj/a.o\nsub\\b.o\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(tmp_path / "lib.a"), str(rsp)],
),
patch.object(
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert build_tool.main() == 0
assert mock_run.call_args[0][0] == [
"ar-bin",
"rcs",
str(tmp_path / "lib.a"),
"obj/a.o",
"sub\\b.o",
]
def test_ar_unquotes_ninja_escaped_paths(tmp_path: Path) -> None:
"""The shim strips a simple surrounding quote, since ninja shell-
quotes special rsp paths, so ar sees the real filename."""
rsp = tmp_path / "t.rsp"
rsp.write_text("'obj/a b.o'\nobj/c.o\n")
with (
patch.object(
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
),
patch.object(build_tool.subprocess, "run") as mock_run,
):
mock_run.return_value.returncode = 0
rc = build_tool.main()
assert rc == 0
assert mock_run.call_args.args[0] == [
"/usr/bin/ar",
"rcs",
"lib.a",
"obj/a b.o",
"obj/c.o",
]
def test_ar_empty_object_list_fails(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A lost object list is an error here, not undefined symbols at link."""
rsp = tmp_path / "t.rsp"
rsp.write_text("\n\n")
with patch.object(
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
):
rc = build_tool.main()
assert rc == 1
assert "no objects listed" in capsys.readouterr().err
def test_ar_batches_long_object_lists(tmp_path: Path) -> None:
"""The expanded argv must stay under the Windows 32767-char limit: a
long object list creates with rcs, then appends with qs."""
archive = tmp_path / "lib.a"
rsp = tmp_path / "lib.a.rsp"
objects = [f"dir/{'x' * 120}_{i}.o" for i in range(400)]
rsp.write_text("\n".join(objects) + "\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
),
patch.object(
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert build_tool.main() == 0
calls = [c[0][0] for c in mock_run.call_args_list]
assert len(calls) > 1
assert calls[0][1] == "rcs"
assert all(c[1] == "qs" for c in calls[1:])
assert [o for c in calls for o in c[3:]] == objects
assert all(sum(len(a) + 1 for a in c) < 32000 for c in calls)
def test_ar_batch_failure_stops(tmp_path: Path) -> None:
"""A failing batch propagates its exit code without running the rest."""
archive = tmp_path / "lib.a"
rsp = tmp_path / "lib.a.rsp"
rsp.write_text("\n".join(f"{'y' * 200}_{i}.o" for i in range(300)) + "\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
),
patch.object(
build_tool.subprocess,
"run",
side_effect=lambda cmd, **kw: (
archive.write_text("partial"),
MagicMock(returncode=3),
)[1],
) as mock_run,
):
assert build_tool.main() == 3
assert mock_run.call_count == 1
# The failed batch must not leave a truncated archive behind
assert not archive.exists()
def test_ar_exception_leaves_no_partial_archive(tmp_path: Path) -> None:
"""A missing ar binary mid-loop must not leave a truncated archive from
earlier successful batches."""
archive = tmp_path / "lib.a"
rsp = tmp_path / "lib.a.rsp"
rsp.write_text("a.o\n")
with (
patch.object(
build_tool.sys,
"argv",
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
),
patch.object(
build_tool.subprocess,
"run",
side_effect=lambda cmd, **kw: (
archive.write_text("partial"),
(_ for _ in ()).throw(FileNotFoundError("no ar")),
),
),
pytest.raises(FileNotFoundError),
):
build_tool.main()
assert not archive.exists()
def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None:
"""A mis-specified ninja rule passing extra operands errors instead of
silently dropping them."""
with patch.object(
build_tool.sys, "argv", ["build_tool", "copy", "a", "b", "extra"]
):
assert build_tool.main() == 1
assert "expected 2 arguments, got 3" in capsys.readouterr().err
def test_copy_same_file_keeps_the_input(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A same-file copy (dst IS src) must not unlink the input, and fails
with a message and exit code like the other shim paths."""
src = tmp_path / "firmware.bin"
src.write_bytes(b"image")
with patch.object(
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(src)]
):
assert build_tool.main() == 1
assert src.read_bytes() == b"image"
assert "failed" in capsys.readouterr().err
def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
"""A failed copy unlinks the destination; a partial firmware image must
never be left on disk."""
dst = tmp_path / "firmware.factory.bin"
dst.write_text("stale")
with (
patch.object(build_tool.shutil, "copyfile", side_effect=OSError("disk full")),
patch.object(
build_tool.sys,
"argv",
["build_tool", "copy", str(tmp_path / "src.bin"), str(dst)],
),
):
assert build_tool.main() == 1
assert not dst.exists()
File diff suppressed because it is too large Load Diff
+27
View File
@@ -520,6 +520,33 @@ def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) ->
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
def test_python_deps_use_uv_when_available(
espidf_mocks: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The python env installs go through uv when on the PATH, pip otherwise."""
monkeypatch.delenv("UV_HTTP_RETRIES", raising=False)
with patch(
"esphome.espidf.framework.shutil.which",
# Keyed on the name: the same which() also probes the default tools
side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None,
):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call, feature_call = espidf_mocks.run_ok.call_args_list[1:3]
upgrade_cmd, feature_cmd = upgrade_call.args[0], feature_call.args[0]
assert upgrade_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert "--python" in upgrade_cmd
assert feature_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "10"
espidf_mocks.run_ok.reset_mock()
monkeypatch.setenv("UV_HTTP_RETRIES", "3") # an explicit user value wins
with patch("esphome.espidf.framework.shutil.which", return_value=None):
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
upgrade_call = espidf_mocks.run_ok.call_args_list[1]
assert upgrade_call.args[0][1:4] == ["-m", "pip", "install"]
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "3"
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."""
+73 -1
View File
@@ -1,3 +1,4 @@
import errno
import io
import logging
import os
@@ -5,7 +6,7 @@ from pathlib import Path
import socket
import stat
import types
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
from hypothesis import given, settings
@@ -966,6 +967,77 @@ def test_copy_file_if_changed_nonexistent_source(tmp_path: Path) -> None:
helpers.copy_file_if_changed(src, dst)
def test_rmtree_removes_tree(tmp_path: Path) -> None:
"""Test rmtree removes a populated directory tree."""
target = tmp_path / "target"
(target / "sub").mkdir(parents=True)
(target / "sub" / "file.txt").write_text("content")
helpers.rmtree(target)
assert not target.exists()
def test_rmtree_nonexistent_path(tmp_path: Path) -> None:
"""Test rmtree on an already-removed path is a no-op."""
helpers.rmtree(tmp_path / "gone")
def test_rmtree_retries_when_directory_repopulated(tmp_path: Path) -> None:
"""Test rmtree retries when a file appears mid-delete (Finder .DS_Store race)."""
target = tmp_path / "target"
(target / "sub").mkdir(parents=True)
real_rmdir = os.rmdir
repopulated = False
def racy_rmdir(path, **kwargs):
nonlocal repopulated
if not repopulated and Path(path).name == "target":
repopulated = True
(target / ".DS_Store").write_text("x") # Finder wins the race
real_rmdir(path, **kwargs)
with patch("os.rmdir", side_effect=racy_rmdir), patch("time.sleep"):
helpers.rmtree(target)
assert repopulated
assert not target.exists()
def test_rmtree_raises_after_retries_exhausted(tmp_path: Path) -> None:
"""Test rmtree gives up on a persistent ENOTEMPTY once attempts run out."""
target = tmp_path / "target"
target.mkdir()
errs = [
OSError(errno.ENOTEMPTY, "Directory not empty", str(target))
for _ in range(helpers.RMTREE_MAX_ATTEMPTS)
]
with (
patch("shutil.rmtree", side_effect=errs) as mock_rmtree,
patch("time.sleep") as mock_sleep,
pytest.raises(OSError, match="Directory not empty") as excinfo,
):
helpers.rmtree(target)
assert mock_rmtree.call_count == helpers.RMTREE_MAX_ATTEMPTS
assert mock_sleep.call_args_list == [call(0.05), call(0.1)]
# Final failure chains to the last retried race
assert excinfo.value is errs[-1]
assert excinfo.value.__cause__ is errs[-2]
def test_rmtree_does_not_retry_other_oserror(tmp_path: Path) -> None:
"""Test rmtree raises non-ENOTEMPTY errors immediately."""
target = tmp_path / "target"
target.mkdir()
err = OSError(errno.EACCES, "Permission denied", str(target))
with (
patch("shutil.rmtree", side_effect=err) as mock_rmtree,
pytest.raises(OSError, match="Permission denied"),
):
helpers.rmtree(target)
assert mock_rmtree.call_count == 1
def test_resolve_ip_address_sorting() -> None:
"""Test that results are sorted by preference."""
# Create multiple address infos with different preferences
+206 -3
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from esphome.core import EsphomeError, Library
from esphome.core import CORE, EsphomeError, Library
import esphome.platformio.library as lib
from esphome.platformio.library import (
SOURCE_KIND_FOR_SUFFIX,
@@ -29,9 +29,13 @@ from esphome.platformio.library import (
)
def _backend(emit=lambda component: None) -> LibraryBackend:
def _backend(emit=lambda component: None, provides=None) -> LibraryBackend:
return LibraryBackend(
platform="espressif32", framework="espidf", emit=emit, cache_key="idf"
platform="espressif32",
framework="espidf",
emit=emit,
cache_key="idf",
provides=provides,
)
@@ -952,3 +956,202 @@ def test_source_kind_map_shape() -> None:
assert SOURCE_KIND_FOR_SUFFIX[".S"] == "aspp"
assert SOURCE_KIND_FOR_SUFFIX[".c"] == "c"
assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx"
# SCons's case-sensitive C++ suffixes: PIO compiles .C as C++
assert SOURCE_KIND_FOR_SUFFIX[".C"] == "cxx"
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
def test_versionless_platform_filtered_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A version-less dependency the platform filter excludes is
deliberately absent, not a drop to warn about."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "Hash", "platforms": "espressif8266"}],
}
},
)
convert_libraries([Library("esphome/A", None, None)], _backend())
assert "has no version to resolve" not in caplog.text
def test_versionless_ignored_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A lib_ignore'd version-less dependency is deliberately excluded, not
a drop; no reconciliation warning."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
)
CORE.platformio_options = {"lib_ignore": ["Hash"]}
convert_libraries([Library("esphome/A", None, None)], _backend())
assert "has no version to resolve" not in caplog.text
def test_versionless_dependency_without_provider_warns(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A backend whose tree could supply the name warns on the drop; one
without provides() can never act on it, so it stays at debug."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
# The duplicate entry warns once (reconciliation dedup)
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
}
},
)
convert_libraries(
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
)
assert (
caplog.text.count(
"Hash of esphome/A has no version to resolve and nothing provides it"
)
== 1
)
caplog.clear()
with caplog.at_level(logging.DEBUG):
convert_libraries([Library("esphome/A", None, None)], _backend())
records = [
r
for r in caplog.records
if "has no version to resolve and nothing provides it" in r.message
]
assert records and all(r.levelno == logging.DEBUG for r in records)
def test_url_version_dependency_is_not_substituted_by_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A URL-valued version names one specific source; the backend-provided
skip must not replace it with the bundled copy."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [
{"name": "Hash", "version": "https://github.com/o/Hash.git"}
],
},
"o/Hash": {"name": "Hash"},
},
)
emitted: list[str] = []
convert_libraries(
[Library("esphome/A", "1.0.0", None)],
_backend(emit=lambda c: emitted.append(c.name), provides=lambda name: True),
)
assert "Skip backend-provided" not in caplog.text
assert "using the library bundled" not in caplog.text
assert any("o/hash" in n.lower() for n in emitted)
def test_versionless_owner_qualified_dependency_warns_despite_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An owner-qualified version-less dependency is not satisfied by
provides(); it must still warn."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "Wire", "owner": "Foo"}],
}
},
)
convert_libraries(
[Library("esphome/A", None, None)],
_backend(provides=lambda name: name == "Wire"),
)
assert "Wire of esphome/A has no version to resolve" in caplog.text
def test_versionless_provided_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An owner-less version-less dependency the backend provides is added
by the backend after emit; no reconciliation warning."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
)
convert_libraries(
[Library("esphome/A", None, None)],
_backend(provides=lambda name: name == "Wire"),
)
assert "has no version to resolve" not in caplog.text
def test_versionless_dependency_requested_top_level_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A version-less dependency the config also requests top-level is in
the build; no drop warning even without a provides backend."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
"Hash": {"name": "Hash"},
},
)
convert_libraries(
[Library("esphome/A", None, None), Library("Hash", None, None)],
_backend(),
)
assert "has no version to resolve" not in caplog.text
def test_versionless_url_ish_dependency_name_warns_cleanly(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A malformed URL-ish dependency name falls to the drop warning, never
a RuntimeError out of the key parser."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
)
convert_libraries(
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
)
assert (
"file:// of esphome/A has no version to resolve and nothing provides it"
in caplog.text
)
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A bare name satisfied by an owner-qualified component's manifest
name is not a drop."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/B": {"name": "B"},
},
)
convert_libraries(
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
_backend(),
)
assert "has no version to resolve" not in caplog.text