Files
esphome/tests/unit_tests/build_gen/test_host.py
T

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