mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""Tests for esphome.build_gen.host (the host ninja generator)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from esphome.arduino.library import ArduinoLibrary
|
|
from esphome.build_gen import host as build_gen
|
|
from esphome.build_helpers.ninja import escape as _e, quote_path as _q
|
|
from esphome.build_helpers.ninja_gen import Flag
|
|
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST
|
|
from esphome.core import CORE, EsphomeError, Library
|
|
from esphome.host.toolchain import PROGRAM_NAME, HostCompilers
|
|
|
|
COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _core(tmp_path: Path) -> None:
|
|
CORE.build_path = tmp_path
|
|
CORE.name = "dev"
|
|
CORE.cpp_standard = "gnu++20"
|
|
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
|
|
|
|
|
|
def _make_src(tmp_path: Path, *names: str) -> Path:
|
|
src = tmp_path / "src"
|
|
for name in names:
|
|
path = src / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("")
|
|
return src
|
|
|
|
|
|
def _render(
|
|
ccache: str | None = None,
|
|
version: str = "g++ 1.0",
|
|
compilers: HostCompilers = COMPILERS,
|
|
) -> tuple[bool, str]:
|
|
with patch.object(build_gen, "_compiler_version", return_value=version):
|
|
changed = build_gen.write_project(compilers, ccache)
|
|
ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja"
|
|
return changed, ninja.read_text()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entries", "compile_flags", "link_flags"),
|
|
[
|
|
(
|
|
["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"],
|
|
[("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)],
|
|
[],
|
|
),
|
|
(["-std=gnu++20"], [("-std=gnu++20",)], []),
|
|
(
|
|
["-lssl", "-L/opt/lib", "-Wl,--gc-sections"],
|
|
[],
|
|
[("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)],
|
|
),
|
|
# A link-only flag travels with its argument
|
|
(
|
|
["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"],
|
|
[],
|
|
[
|
|
("-Xlinker", "--wrap=malloc"),
|
|
("-z", "noexecstack"),
|
|
("-framework", "Cocoa"),
|
|
],
|
|
),
|
|
# Both lines, as SCons routes unclassified flags
|
|
(
|
|
["-g -O2", "-fsanitize=address", "-pthread", "--coverage"],
|
|
[
|
|
("-g",),
|
|
("-O2",),
|
|
("-fsanitize=address",),
|
|
("-pthread",),
|
|
("--coverage",),
|
|
],
|
|
[
|
|
("-g",),
|
|
("-O2",),
|
|
("-fsanitize=address",),
|
|
("-pthread",),
|
|
("--coverage",),
|
|
],
|
|
),
|
|
# A flag with a path argument travels with it, glued on or not
|
|
(
|
|
["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"],
|
|
[("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)],
|
|
[],
|
|
),
|
|
],
|
|
)
|
|
def test_split_flags(
|
|
entries: list[str], compile_flags: list[Flag], link_flags: list[Flag]
|
|
) -> None:
|
|
flags = build_gen.parse_flags(entries, "esphome")
|
|
assert build_gen.split_flags(flags) == (compile_flags, link_flags)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"entries",
|
|
[
|
|
# The flag is the last token of its entry
|
|
["-g -include"],
|
|
# Entries are a set: the next entry is not this flag's argument
|
|
["-include", "pre.h"],
|
|
["-framework", "-pthread"],
|
|
# A path never starts with "-"
|
|
["-include -pthread"],
|
|
],
|
|
)
|
|
def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None:
|
|
with pytest.raises(EsphomeError, match="with no argument; write the flag"):
|
|
build_gen.parse_flags(entries, "esphome")
|
|
|
|
|
|
def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None:
|
|
assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [
|
|
("-Xlinker", "--wrap=malloc")
|
|
]
|
|
|
|
|
|
def test_flag_lists_route_the_standard() -> None:
|
|
"""cpp_standard wins for C++; C never sees a C++ standard."""
|
|
CORE.build_flags = {
|
|
"-DUSE_HOST",
|
|
"-Iinc",
|
|
"-Wl,-x",
|
|
"-g",
|
|
"-lssl",
|
|
"-std=c++17",
|
|
"-std=gnu17",
|
|
}
|
|
CORE.cxx_build_flags = {"-Wno-volatile"}
|
|
cflags, cxxflags, link_flags = build_gen._flag_lists()
|
|
# The relative include is anchored at the build path
|
|
inc = f"-I{CORE.build_path / 'inc'}"
|
|
assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"]
|
|
assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"]
|
|
assert link_flags == ["-Wl,-x", "-g", "-lssl"]
|
|
|
|
|
|
def test_flag_lists_without_cpp_standard_keeps_user_std() -> None:
|
|
CORE.cpp_standard = None
|
|
CORE.build_flags = {"-std=c++17"}
|
|
cflags, cxxflags, _link = build_gen._flag_lists()
|
|
assert cflags == []
|
|
assert cxxflags == ["-std=c++17"]
|
|
|
|
|
|
def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None:
|
|
CORE.build_flags = {"-g", "-DUSE_HOST"}
|
|
CORE.build_unflags = {"-g", "-Onope"}
|
|
with caplog.at_level(logging.WARNING):
|
|
cflags, cxxflags, link_flags = build_gen._flag_lists()
|
|
assert "-g" not in cflags + cxxflags + link_flags
|
|
assert "-DUSE_HOST" in cflags
|
|
assert "build_unflags entries matched no build flag: -Onope" in caplog.text
|
|
|
|
|
|
def test_resolve_host_libraries_without_libraries() -> None:
|
|
with patch("esphome.arduino.library.resolve_libraries") as resolve:
|
|
assert build_gen._resolve_host_libraries() == []
|
|
resolve.assert_not_called()
|
|
|
|
|
|
def test_resolve_host_libraries_is_framework_less() -> None:
|
|
CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0"))
|
|
lib = ArduinoLibrary(name="lvgl")
|
|
with patch(
|
|
"esphome.arduino.library.resolve_libraries", return_value=[lib]
|
|
) as resolve:
|
|
assert build_gen._resolve_host_libraries() == [lib]
|
|
resolve.assert_called_once_with(
|
|
None,
|
|
pio_platform="native",
|
|
board_mcu="host",
|
|
cache_key="host",
|
|
framework=None,
|
|
manifest_optional=True,
|
|
)
|
|
|
|
|
|
def test_write_project_requires_generated_sources(tmp_path: Path) -> None:
|
|
with pytest.raises(EsphomeError, match="Generated source directory .* is missing"):
|
|
build_gen.write_project(COMPILERS, None)
|
|
_make_src(tmp_path, "esphome.h")
|
|
with pytest.raises(EsphomeError, match="No source files found"):
|
|
_render()
|
|
|
|
|
|
def test_write_project_emits_every_source_kind(tmp_path: Path) -> None:
|
|
src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h")
|
|
CORE.build_flags = {"-DUSE_HOST", "-g"}
|
|
changed, ninja = _render(ccache="/usr/bin/ccache")
|
|
assert changed is True
|
|
# A compiler override's arguments follow the program
|
|
assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja
|
|
assert f"cxx = {_q('/usr/bin/g++')}" in ninja
|
|
assert f"ccache = {_q('/usr/bin/ccache')}" in ninja
|
|
assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja
|
|
assert (
|
|
f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja
|
|
)
|
|
assert "build obj/src/x.S.o: aspp " in ninja
|
|
assert "build obj/src/y.s.o: asm " in ninja
|
|
assert _e(src / "h.h") not in ninja
|
|
# __FILE__ stays relative to the build path, as it was under PlatformIO,
|
|
# whether the source arrives absolute or rewritten by ccache
|
|
sep = os.sep
|
|
maps = (
|
|
f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} "
|
|
f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}"
|
|
)
|
|
assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
|
|
assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
|
|
# Assembly gets the defines and includes only
|
|
assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja
|
|
assert "linkflags = -g\n" in ninja
|
|
assert "libdirflags = \n" in ninja
|
|
assert "libflags = \n" in ninja
|
|
assert "rule ar" not in ninja
|
|
assert (
|
|
f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o "
|
|
f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n"
|
|
) in ninja
|
|
# Unchanged content reports no change so the compile DB can be reused
|
|
changed, _ = _render(ccache="/usr/bin/ccache")
|
|
assert changed is False
|
|
|
|
|
|
def test_write_project_precompiles_the_core_headers(tmp_path: Path) -> None:
|
|
"""C++ src edges load one precompiled prefix; C and assembly do not."""
|
|
src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S")
|
|
_, ninja = _render()
|
|
build_dir = tmp_path / ".pioenvs" / "dev"
|
|
source = build_dir / "esphome_pch_src.h"
|
|
assert source.read_text() == '#include "esphome/core/pch_prefix.h"\n'
|
|
assert "rule pch\n" in ninja
|
|
sum_path = build_dir / "esphome_pch.h.gch.sum"
|
|
assert (
|
|
f"build esphome_pch.h.gch: pch {_e(source)} | esphome_pch.h.gch.sum\n" in ninja
|
|
)
|
|
assert "srccxxflags = -Winvalid-pch " in ninja
|
|
assert (
|
|
f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')} | esphome_pch.h.gch\n"
|
|
" flags = $srccxxflags\n"
|
|
) in ninja
|
|
assert (
|
|
f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}\n"
|
|
) in ninja
|
|
assert f"build obj/src/x.S.o: aspp {_e(src / 'x.S')}\n" in ninja
|
|
# An updated compiler behind the same path rebuilds the header
|
|
first = sum_path.read_text()
|
|
_render(version="g++ 2.0")
|
|
assert sum_path.read_text() != first
|
|
|
|
|
|
def test_compiler_version_asks_the_compiler() -> None:
|
|
assert build_gen._compiler_version((sys.executable,)).startswith("Python ")
|
|
|
|
|
|
def test_write_project_pch_disabled(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
src = _make_src(tmp_path, "main.cpp")
|
|
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
|
# The compiler is not asked for its version either
|
|
with patch.object(build_gen, "_compiler_version", side_effect=AssertionError):
|
|
build_gen.write_project(COMPILERS, None)
|
|
ninja = (tmp_path / ".pioenvs" / "dev" / "build.ninja").read_text()
|
|
assert "esphome_pch" not in ninja
|
|
assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}\n" in ninja
|
|
|
|
|
|
def test_write_project_pch_skipped_for_a_user_force_include(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A -include in the build flags precedes the prefix, so the compiler
|
|
would never load the precompiled header."""
|
|
_make_src(tmp_path, "main.cpp")
|
|
CORE.build_flags = {"-include pre.h"}
|
|
_, ninja = _render()
|
|
assert "esphome_pch" not in ninja
|
|
assert "prevents the precompiled header" in caplog.text
|
|
|
|
|
|
def test_write_project_pch_skipped_for_a_force_include_in_the_compiler(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""CXX="g++ -include pre.h" puts it ahead of every flag."""
|
|
_make_src(tmp_path, "main.cpp")
|
|
override = HostCompilers(cc=COMPILERS.cc, cxx=("/usr/bin/g++", "-include", "pre.h"))
|
|
_, ninja = _render(compilers=override)
|
|
assert "esphome_pch" not in ninja
|
|
assert "prevents the precompiled header" in caplog.text
|
|
|
|
|
|
def test_write_project_without_ccache(tmp_path: Path) -> None:
|
|
_make_src(tmp_path, "main.cpp")
|
|
_changed, ninja = _render()
|
|
assert "ccache = \n" in ninja
|
|
|
|
|
|
def test_write_project_routes_user_link_flags(tmp_path: Path) -> None:
|
|
_make_src(tmp_path, "main.cpp")
|
|
lib_dir = tmp_path / "opt" / "lib"
|
|
# Forward slashes: build flags are lexed like a POSIX shell line, which
|
|
# reads a backslash as an escape
|
|
CORE.build_flags = {
|
|
f"-L{lib_dir.as_posix()}",
|
|
"-lcrypto",
|
|
"-Wl,-framework,Security",
|
|
}
|
|
_changed, ninja = _render()
|
|
assert "linkflags = -Wl,-framework,Security\n" in ninja
|
|
assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja
|
|
assert "libflags = -lcrypto\n" in ninja
|
|
|
|
|
|
def _libraries(tmp_path: Path) -> list[ArduinoLibrary]:
|
|
lib_dir = tmp_path / "libs"
|
|
for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"):
|
|
path = lib_dir / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("")
|
|
archived = ArduinoLibrary(
|
|
name="foo",
|
|
sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"],
|
|
include_dirs=[lib_dir / "foo/src"],
|
|
flags=["-DFOO=1"],
|
|
link_dirs=[lib_dir / "foo/lib"],
|
|
link_libs=["bar"],
|
|
link_flags=["-Wl,--gc-sections"],
|
|
)
|
|
direct = ArduinoLibrary(
|
|
name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False
|
|
)
|
|
header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"])
|
|
return [archived, direct, header_only]
|
|
|
|
|
|
def test_write_project_with_libraries(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
src = _make_src(tmp_path, "main.cpp")
|
|
libs = _libraries(tmp_path)
|
|
with (
|
|
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
|
|
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool,
|
|
patch.object(build_gen.sys, "platform", "linux"),
|
|
caplog.at_level(logging.DEBUG),
|
|
):
|
|
_changed, ninja = _render()
|
|
find_tool.assert_called_once_with("AR", ("ar",))
|
|
assert (
|
|
f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp"
|
|
in ninja
|
|
)
|
|
lib_dir = tmp_path / "libs"
|
|
# Every library's include dir joins the global include path
|
|
assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja
|
|
assert "linkflags = -Wl,--gc-sections\n" in ninja
|
|
assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja
|
|
assert "libflags = -lbar\n" in ninja
|
|
# Library sources compile with the library's own flags, rooted at their
|
|
# common parent; its own include dirs lead the line so another library's
|
|
# header of the same name cannot shadow them
|
|
own = f"-I{_q(lib_dir / 'foo/src')}"
|
|
assert "$own_includes $cxxflags $flags" in ninja
|
|
assert (
|
|
f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n"
|
|
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
|
|
)
|
|
assert (
|
|
f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n"
|
|
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
|
|
)
|
|
assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja
|
|
# libArchive: false objects link directly; the archive is an order-only
|
|
# input wrapped in a group for GNU ld
|
|
assert (
|
|
f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o "
|
|
"| libfoo.a\n"
|
|
" archives = -Wl,--start-group libfoo.a -Wl,--end-group\n"
|
|
) in ninja
|
|
assert "Library hdr has no source files" in caplog.text
|
|
|
|
|
|
def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None:
|
|
_make_src(tmp_path, "main.cpp")
|
|
libs = _libraries(tmp_path)
|
|
with (
|
|
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
|
|
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"),
|
|
patch.object(build_gen.sys, "platform", "darwin"),
|
|
):
|
|
_changed, ninja = _render()
|
|
assert " archives = libfoo.a\n" in ninja
|
|
assert "--start-group" not in ninja
|
|
|
|
|
|
def test_build_unflags_remove_a_flag_with_its_argument(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Only the named pair goes; the same path under another flag stays."""
|
|
CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"}
|
|
CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"}
|
|
with caplog.at_level(logging.WARNING):
|
|
cflags, _cxxflags, link_flags = build_gen._flag_lists()
|
|
assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")]
|
|
assert link_flags == ["-g"]
|
|
assert "matched no build flag" not in caplog.text
|
|
|
|
|
|
def test_build_unflags_warn_when_nothing_matches(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
CORE.build_flags = {"-g"}
|
|
CORE.build_unflags = {"-isystem inc", "-DNOPE"}
|
|
with caplog.at_level(logging.WARNING):
|
|
build_gen._flag_lists()
|
|
assert "matched no build flag: -DNOPE, -isystem " in caplog.text
|
|
|
|
|
|
def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None:
|
|
CORE.build_flags = {"-Iinc", "-g"}
|
|
CORE.build_unflags = {"-Iinc"}
|
|
with caplog.at_level(logging.WARNING):
|
|
cflags, _cxxflags, _link = build_gen._flag_lists()
|
|
assert cflags == ["-g"]
|
|
assert "matched no build flag" not in caplog.text
|