Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain

# Conflicts:
#	esphome/components/esp32/__init__.py
This commit is contained in:
J. Nick Koston
2026-08-23 10:17:28 -05:00
48 changed files with 592 additions and 328 deletions
+91 -1
View File
@@ -574,11 +574,101 @@ def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
assert not cache.exists()
@pytest.mark.parametrize(
"cached",
(
{"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"},
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++"},
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}},
),
ids=("launcher-cxx", "no-includes", "no-build-list"),
)
def test_load_or_build_idedata_regenerates_invalid_cache(
tmp_path: Path, cached: dict
) -> None:
"""A cache written by an older version fails validation and regenerates."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(json.dumps(cached))
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "includes" in data
def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None:
"""A served cache carries the current ELF path, not the one it was written with."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(
json.dumps(
{
"cc_path": "/tools/gcc",
"cxx_path": "/tools/g++",
"includes": {"build": [], "toolchain": []},
"prog_path": "/old/location/firmware.elf",
}
)
)
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
assert data["prog_path"] == str(tmp_path / "firmware.elf")
def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None:
"""Valid JSON that is not a list raises by name, inside the best-effort tuple."""
compile_commands = tmp_path / "compile_commands.json"
for bad in ("{}", "null", '"text"'):
compile_commands.write_text(bad)
with pytest.raises(EsphomeError, match="not a compile-command list"):
idedata.idedata_from_build(compile_commands)
def test_idedata_from_build_same_file_rsp_commands_never_dedupe(
tmp_path: Path,
) -> None:
"""Two objects built from one source with different .rsp files keep both
include sets; the rsp sentinel keys on the output, not the source."""
file = f"{ABS}build/src/esphome/core/shared.cpp"
entries = []
for name in ("a", "b"):
rsp = tmp_path / f"{name}.o.rsp"
rsp.write_text(f"-I{ABS}inc/{name}")
entries.append(
{
"directory": str(tmp_path),
"file": file,
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
"output": f"{name}.o",
}
)
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps(entries))
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.idedata_from_build(compile_commands)
joined = " ".join(data["includes"]["build"])
assert "inc/a" in joined and "inc/b" in joined
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
"""A valid cache newer than the compile DB is served without re-parsing."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(json.dumps({"cc_path": "/tools/gcc", "cached": True}))
cache.write_text(
json.dumps(
{
"cc_path": "/tools/gcc",
"cxx_path": "/tools/g++",
"includes": {"build": ["/inc"], "toolchain": []},
"cached": True,
}
)
)
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
data = idedata.load_or_build_idedata(
+24
View File
@@ -6,6 +6,7 @@ from unittest.mock import patch
import pytest
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
@@ -18,6 +19,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
PlatformFramework,
)
from esphome.core import Define
def test_filter_source_files_from_platform_esp32() -> None:
@@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
}
with pytest.raises(ValueError, match="unknown platform"):
frameworks_for_platforms(["esp32", "not_a_platform"])
def test_filter_source_files_from_defines() -> None:
"""Files are excluded unless one of their defines is set."""
files_map: dict[str, str | tuple[str, ...]] = {
"filter.cpp": "USE_SENSOR_FILTER",
"automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"),
}
filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map)
with patch("esphome.config_helpers.CORE") as mock_core:
mock_core.defines = {Define("USE_SENSOR_FILTER")}
assert filter_func() == ["automation.cpp"]
mock_core.defines = {Define("USE_MULTI_CLICK")}
assert filter_func() == ["filter.cpp"]
mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")}
assert filter_func() == []
mock_core.defines = set()
assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"]
+2 -4
View File
@@ -256,8 +256,7 @@ class Test_write_file_if_changed:
def test_damaged_existing_file_is_replaced(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
):
"""A non-UTF-8 existing file is logged and overwritten; aborting
would block the very regeneration that fixes it."""
"""A non-UTF-8 existing file is logged and overwritten."""
dst = tmp_path / "generated.txt"
dst.write_bytes(b"\xff\xfe")
@@ -267,8 +266,7 @@ class Test_write_file_if_changed:
assert "Replacing damaged file" in caplog.text
def test_unreadable_existing_file_still_raises(self, tmp_path: Path):
"""An OSError on the comparison read may hide an intact file, so it
propagates as EsphomeError instead of unlinking."""
"""An OSError on the comparison read still raises EsphomeError."""
dst = tmp_path / "generated.txt"
dst.write_text("intact")
@@ -302,6 +302,33 @@ def test_apply_extra_script_missing_script_raises(tmp_path) -> None:
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
@pytest.mark.parametrize("bad", (["a.py"], {"esp32": "a.py"}), ids=("list", "dict"))
def test_apply_extra_script_non_string_raises(tmp_path, bad) -> None:
"""A non-string extraScript fails naming the library, not with a TypeError."""
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": bad}}
with pytest.raises(EsphomeError, match="of library owner/name must be a string"):
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
def test_extra_script_cpppath_captured_as_include_flags(tmp_path, monkeypatch):
"""CPPPATH entries translate to -I flags anchored like LIBPATH."""
(tmp_path / "include").mkdir()
outside = tmp_path.parent / "system_inc"
outside.mkdir(exist_ok=True)
elsewhere = tmp_path.parent / "not_the_library_dir"
elsewhere.mkdir(exist_ok=True)
monkeypatch.chdir(elsewhere)
result = ExtraScriptResult(cpppath=["include", str(outside), 7])
flags = captured_as_build_flags(result, library_dir=tmp_path)
assert flags == ["-Iinclude", f"-I{outside.resolve()}"]
def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) -> None:
"""A crashed script yields an empty result: half-applied flags could
build wrong-output firmware that links cleanly."""
@@ -396,6 +423,6 @@ def test_uncaptured_append_key_warns_once(caplog) -> None:
env = _FakeSConsEnv(
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
)
env.Append(CPPPATH=["a"])
env.Append(CPPPATH=["b"])
assert caplog.text.count("env.Append(CPPPATH=...) is not captured") == 1
env.Append(RANLIBFLAGS=["a"])
env.Append(RANLIBFLAGS=["b"])
assert caplog.text.count("env.Append(RANLIBFLAGS=...) is not captured") == 1
+31 -3
View File
@@ -205,8 +205,19 @@ def test_print_summary_non_dict_json_warns(tmp_path, caplog) -> None:
def test_print_summary_zero_app_partition_warns(tmp_path, caplog) -> None:
"""A malformed partition row parsing to 0 must not render a 0% bar for
CI's memory-impact extraction to ingest."""
"""A partition row with size 0 drops the Flash bar instead of rendering 0%."""
size_json = tmp_path / "size.json"
size_json.write_text(
'{"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": 100}'
)
partitions = tmp_path / "partitions.csv"
partitions.write_text("app0, app, ota_0, 0x10000, 0,\n")
print_summary(size_json, partitions)
assert "app partition size is" in caplog.text
def test_print_summary_blank_partition_size_warns(tmp_path, caplog) -> None:
"""A blank size cell raises ValueError by name instead of parsing to 0."""
size_json = tmp_path / "size.json"
size_json.write_text(
'{"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": 100}'
@@ -214,4 +225,21 @@ def test_print_summary_zero_app_partition_warns(tmp_path, caplog) -> None:
partitions = tmp_path / "partitions.csv"
partitions.write_text("app0, app, ota_0, 0x10000, ,\n")
print_summary(size_json, partitions)
assert "app partition size is" in caplog.text
assert "blank partition size cell" in caplog.text
@pytest.mark.parametrize(
"payload",
(
'{"memory_types": []}',
'{"memory_types": {"DRAM": 5}}',
'{"memory_types": {"DRAM": {"used": "x", "size": "y"}}}',
),
ids=("non-dict-memory-types", "scalar-region", "non-numeric-sizes"),
)
def test_print_summary_nested_shapes_never_raise(tmp_path, caplog, payload) -> None:
"""The blanket guard keeps unexpected nested shapes from raising."""
size_json = tmp_path / "size.json"
size_json.write_text(payload)
print_summary(size_json, tmp_path / "partitions.csv")
assert "Skipping size summary" in caplog.text