Merge branch 'esp8266-native-library-backend' into esp8266-native-build-spec

This commit is contained in:
J. Nick Koston
2026-08-20 15:12:49 -05:00
15 changed files with 216 additions and 34 deletions
+27 -1
View File
@@ -27,7 +27,8 @@ def test_ar_removes_stale_archive(tmp_path: Path) -> None:
):
assert build_tool.main() == 0
assert not archive.exists()
assert mock_run.call_args[0][0] == ["ar-bin", "rc", str(archive), f"@{rsp}"]
# The rspfile is expanded by the shim (GNU ar would escape backslashes)
assert mock_run.call_args[0][0] == ["ar-bin", "rc", str(archive), "a.o"]
def test_copy(tmp_path: Path) -> None:
@@ -61,3 +62,28 @@ def test_runs_as_script(tmp_path: Path) -> None:
)
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",
"rc",
str(tmp_path / "lib.a"),
"obj/a.o",
"sub\\b.o",
]
@@ -24,16 +24,26 @@ _COMMON_LD_SNIPPET = """\
} >dram0_0_seg :dram0_0_phdr
"""
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
# the generated common ld only)
_FLASH_LD_SNIPPET = """\
MEMORY
{
dport0_0_seg : org = 0x3FF00000, len = 0x10
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
iram1_0_seg : org = 0x40100000, len = 0x8000
irom0_0_seg : org = 0x40201010, len = 0xfeff0
}
"""
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
# suffix the patcher must leave in place
_COMMON_LD_MEMORY_SNIPPET = """\
MEMORY
{
iram1_0_seg : org = 0x40100000, len = 0x8000ul
}
"""
def test_relocate_ratetable_inserts_after_data_start() -> None:
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
@@ -52,15 +62,32 @@ def test_relocate_ratetable_requires_anchor() -> None:
def test_testing_memory_patches_enlarge_segments() -> None:
patched = apply_testing_memory_patches(
_FLASH_LD_SNIPPET, ("iram1_0_seg", "dram0_0_seg", "irom0_0_seg")
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
)
assert segment_length(patched, "iram1_0_seg") == 0x200000
assert segment_length(patched, "dram0_0_seg") == 0x200000
assert segment_length(patched, "irom0_0_seg") == 0x2000000
# Untouched segments keep their sizes
assert segment_length(patched, "dport0_0_seg") == 0x10
def test_testing_memory_patches_keep_ul_suffix() -> None:
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
replace only the hex digits, as testing_mode.py.script does."""
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
assert "len = 0x200000ul" in patched
assert segment_length(patched, "iram1_0_seg") == 0x200000
def test_segment_length_requires_whole_name() -> None:
"""A name must match its own line, never inside a longer segment name."""
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
def test_testing_memory_patches_unknown_segment_raises() -> None:
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
def test_segment_length() -> None:
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
@@ -304,3 +304,18 @@ def test_resolve_libraries_lib_ignore_covers_bundled_dependencies(
libs = component.resolve_libraries(framework)
assert [lib.name for lib in libs] == ["some__External"]
def test_bundled_library_prefers_library_json(tmp_path: Path) -> None:
"""A bundled library.json wins over library.properties (PIO semantics);
its build section is honored."""
framework = _make_framework(tmp_path)
lib_dir = framework / "libraries" / "GDBStub"
(lib_dir / "custom").mkdir(parents=True)
(lib_dir / "custom" / "gdb.cpp").write_text("")
(lib_dir / "library.properties").write_text("name=GDBStub\n")
(lib_dir / "library.json").write_text(
'{"name": "GDBStub", "build": {"srcDir": "custom"}}'
)
lib = component._bundled_library(framework, "GDBStub")
assert [s.name for s in lib.sources] == ["gdb.cpp"]
@@ -64,6 +64,9 @@ def test_pio_system(system: str, machine: str, expected: str) -> None:
[
("FreeBSD", "amd64"),
("Linux", "ppc64le"),
("Darwin", "ppc"),
("Darwin", ""),
("Windows", "ia64"),
],
)
def test_pio_system_unsupported_host_raises(system: str, machine: str) -> None:
@@ -88,10 +91,13 @@ def test_registry_download_network_error_is_clean_and_retried() -> None:
with (
patch("requests.get", side_effect=requests.ConnectionError("boom")) as mock_get,
patch.object(framework.time, "sleep") as mock_sleep,
pytest.raises(EsphomeError, match="Could not query the package registry"),
):
framework._registry_download("pkg", "1.0.0")
assert mock_get.call_count == 3
# Backed-off retries, not one burst
assert mock_sleep.call_count == 3
def test_registry_download_retries_transient_error() -> None:
@@ -109,6 +115,7 @@ def test_registry_download_retries_transient_error() -> None:
)
with (
patch("requests.get", side_effect=[requests.ConnectionError("boom"), resp]),
patch.object(framework.time, "sleep"),
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
assert framework._registry_download("pkg", "1.0.0") == (
@@ -439,3 +446,23 @@ def test_ccache_env_requires_build_path() -> None:
pytest.raises(ValueError, match="build_path"),
):
framework.ccache_env()
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
"""Calling the installer below the floor fails before any download."""
with pytest.raises(EsphomeError, match=">= 3.1.1"):
framework.check_and_install(cv.Version(3, 0, 2))
def test_install_package_uses_hard_lock(tmp_path: Path) -> None:
"""The install lock must never degrade to a soft (existence) lock."""
dest = tmp_path / "pkg"
with (
patch("filelock.FileLock") as mock_lock,
patch.object(framework, "download_from_mirrors"),
patch.object(framework, "archive_extract_all") as mock_extract,
patch.object(framework, "_pio_system", return_value="linux_x86_64"),
):
mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir(exist_ok=True)
framework._install_package("pkg", "1.0.0", dest, ["http://m"])
assert mock_lock.call_args.kwargs["fallback_to_soft"] is False
@@ -3181,3 +3181,28 @@ def test_require_platformio_toolchain() -> None:
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
validator(config)
@pytest.mark.parametrize(
("platform", "minimal_config"),
[
("host", {}),
("rp2", {"board": "rpipicow"}),
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
],
)
def test_every_platformio_only_platform_rejects_arduino_toolchain(
platform: str, minimal_config: dict
) -> None:
"""The invariant every native-toolchain gate relies on: a platform that
cannot serve a CLI toolchain rejects it at validation (esp32, esp8266,
and nrf52 pin this in their own suites)."""
import importlib
from esphome.const import Toolchain
from esphome.core import CORE
module = importlib.import_module(f"esphome.components.{platform}")
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
module.CONFIG_SCHEMA(dict(minimal_config))