mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Extract the shared native-build helpers from the ESP-IDF backend
This commit is contained in:
@@ -26,11 +26,11 @@ from esphome.platformio.library import (
|
||||
GitSource,
|
||||
URLSource,
|
||||
_node_key,
|
||||
_normalize_dependencies,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_resolve_registry_version,
|
||||
collect_filtered_files,
|
||||
normalize_dependencies,
|
||||
parse_library_properties,
|
||||
split_list_by_condition,
|
||||
)
|
||||
|
||||
@@ -510,7 +510,7 @@ empty=
|
||||
"""
|
||||
)
|
||||
|
||||
result = _parse_library_properties(f)
|
||||
result = parse_library_properties(f)
|
||||
|
||||
assert result["name"] == "Test"
|
||||
assert result["version"] == "1.0"
|
||||
@@ -680,22 +680,22 @@ def test_node_key_registry_bare_name():
|
||||
|
||||
|
||||
def test_normalize_dependencies_none():
|
||||
assert _normalize_dependencies(None) == []
|
||||
assert normalize_dependencies(None) == []
|
||||
|
||||
|
||||
def test_normalize_dependencies_list_form():
|
||||
deps = [{"name": "foo", "version": "1.0"}]
|
||||
assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
|
||||
assert normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
|
||||
|
||||
|
||||
def test_normalize_dependencies_dict_form():
|
||||
out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
|
||||
out = normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
|
||||
assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out
|
||||
assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out
|
||||
|
||||
|
||||
def test_normalize_dependencies_dict_form_nested_spec():
|
||||
out = _normalize_dependencies(
|
||||
out = normalize_dependencies(
|
||||
{"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}}
|
||||
)
|
||||
assert out == [
|
||||
@@ -1190,3 +1190,92 @@ def test_idf_component_download_passes_salt() -> None:
|
||||
"owner/name", force=True, salt="abcd1234", namespace="idf"
|
||||
)
|
||||
assert c.path == Path("/converted/owner/name")
|
||||
|
||||
|
||||
def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None:
|
||||
"""The shared helper resolves a callable idf_target lazily and normalizes
|
||||
a string ``build.flags`` value into a list before extending it."""
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": "-DBASE=1"}}
|
||||
|
||||
apply_extra_script(c, lambda: "esp8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
# No extraScript declared: nothing happens, the target is never resolved
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {}}
|
||||
apply_extra_script(c, lambda: pytest.fail("target resolved without a script"))
|
||||
|
||||
# A script that captures nothing leaves the flags untouched
|
||||
script = tmp_path / "noop.py"
|
||||
script.write_text("pass\n")
|
||||
c.data = {"build": {"extraScript": "noop.py"}}
|
||||
apply_extra_script(c, "esp8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path) -> None:
|
||||
"""Un-captured env vars and unsupported env methods are silent no-ops."""
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Replace(CC='clang')\nenv.Append(UNCAPTURED=['x'], LIBS='single')\n"
|
||||
)
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, "esp8266")
|
||||
assert c.data["build"]["flags"] == ["-lsingle"]
|
||||
|
||||
|
||||
def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
|
||||
"""A raising extra-script is best-effort: logged and skipped."""
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, "esp8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
assert "skipping" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_pio_platform(tmp_path) -> None:
|
||||
"""The backend's platform token is exposed to the script as PIOPLATFORM."""
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env.get('PIOPLATFORM')])\n")
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
apply_extra_script(c, "esp8266", pio_platform="espressif8266")
|
||||
assert c.data["build"]["flags"] == ["-lespressif8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_missing_script_logged(tmp_path, caplog) -> None:
|
||||
"""A declared but absent extraScript is skipped with a visible warning:
|
||||
its captured link flags are lost."""
|
||||
from esphome.espidf.extra_script import apply_extra_script
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "nope.py"}}
|
||||
apply_extra_script(c, "esp8266")
|
||||
assert "not found" in caplog.text
|
||||
|
||||
@@ -262,3 +262,101 @@ def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
|
||||
|
||||
def test_parse_entry_strips_launcher_prefix() -> None:
|
||||
"""A launcher-wrapped compile names the compiler second; the exact
|
||||
configured launcher is stripped, not anything ccache-shaped."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
|
||||
"-c app.cpp -o app.cpp.o",
|
||||
)
|
||||
cxx_path, defines, _, _ = idedata._parse_entry(
|
||||
entry, launcher="/opt/homebrew/bin/ccache"
|
||||
)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert defines == ["USE_ESP8266"]
|
||||
# Without a configured launcher nothing is stripped, even a token that
|
||||
# happens to be named ccache -- but the surprise is warned about
|
||||
cxx_path, _, _, _ = idedata._parse_entry(entry)
|
||||
assert cxx_path == "/opt/homebrew/bin/ccache"
|
||||
|
||||
|
||||
def test_parse_entry_warns_when_first_token_is_not_a_compiler(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o",
|
||||
)
|
||||
idedata._parse_entry(entry)
|
||||
assert "does not start with a compiler" in caplog.text
|
||||
caplog.clear()
|
||||
idedata._parse_entry(entry, launcher="/opt/homebrew/bin/ccache")
|
||||
assert "does not start with a compiler" not in caplog.text
|
||||
|
||||
|
||||
def _write_compile_commands(tmp_path: Path) -> Path:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
return compile_commands
|
||||
|
||||
|
||||
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache" / "test.json"
|
||||
with patch.object(
|
||||
idedata, "_get_toolchain_includes", return_value=["/toolchain/include"]
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["cc_path"] == "/tools/gcc"
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
assert json.loads(cache.read_text()) == data
|
||||
|
||||
# A fresh cache is served without re-parsing the compile DB
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
== data
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ("not json", json.dumps({"no_cc_path": True})):
|
||||
cache.write_text(bad)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "_get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert "cc_path" in data
|
||||
|
||||
@@ -531,3 +531,25 @@ def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch):
|
||||
top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
|
||||
|
||||
assert top[0].dependencies == []
|
||||
|
||||
|
||||
def test_split_flag_entry_unbalanced_quote_is_clean() -> None:
|
||||
"""A malformed flags entry raises EsphomeError, not a raw ValueError."""
|
||||
from esphome.platformio.library import split_flag_entry
|
||||
|
||||
assert split_flag_entry('-DX="a b"', "library x") == ["-DX=a b"]
|
||||
# join_flag_args re-glues a spaced -D like ParseFlags does
|
||||
from esphome.platformio.library import join_flag_args
|
||||
|
||||
assert join_flag_args(["-D", "FOO=1", "-Os"], "x") == ["-DFOO=1", "-Os"]
|
||||
with pytest.raises(EsphomeError, match=r"Malformed build flag.*library x"):
|
||||
split_flag_entry('-DX="unclosed', "library x")
|
||||
|
||||
|
||||
def test_join_flag_args_trailing_bare_flag_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
from esphome.platformio.library import join_flag_args
|
||||
|
||||
assert join_flag_args(["-Os", "-l"], "library x") == ["-Os"]
|
||||
assert "Ignoring trailing '-l'" in caplog.text
|
||||
|
||||
@@ -126,3 +126,33 @@ def test_print_summary_handles_no_memory_types(
|
||||
size_json = _write_size_json(tmp_path, {"image_size": 0})
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_format_bar_zero_total() -> None:
|
||||
"""A zero total must not divide by zero."""
|
||||
from esphome.espidf.size_summary import format_bar
|
||||
|
||||
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
|
||||
|
||||
|
||||
def test_print_summary_flash_line(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""image_size + a factory app partition produce the Flash line."""
|
||||
size_json = tmp_path / "esp_idf_size.json"
|
||||
size_json.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"memory_types": {"DRAM": {"used": 100, "size": 200}},
|
||||
"image_size": 500,
|
||||
}
|
||||
)
|
||||
)
|
||||
partitions = tmp_path / "partitions.csv"
|
||||
partitions.write_text(
|
||||
"# name, type, subtype, offset, size\napp0, app, factory, 0x10000, 0x100000\n"
|
||||
)
|
||||
print_summary(size_json, partitions)
|
||||
out = capsys.readouterr().out
|
||||
assert "RAM: [===== ] 50.0% (used 100 bytes from 200 bytes)" in out
|
||||
assert "Flash: [ ] 0.0% (used 500 bytes from 1048576 bytes)" in out
|
||||
|
||||
Reference in New Issue
Block a user