Files
esphome/tests/unit_tests/test_host_toolchain.py
T

330 lines
12 KiB
Python

"""Tests for esphome.host.toolchain (the native host build driver)."""
from __future__ import annotations
from collections.abc import Generator
import logging
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_HOST,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.host import toolchain
from esphome.host.toolchain import PROGRAM_NAME
@pytest.fixture(autouse=True)
def _core(tmp_path: Path) -> None:
CORE.config_path = tmp_path / "dev.yaml"
CORE.build_path = tmp_path
CORE.name = "dev"
CORE.toolchain = Toolchain.HOST
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
def _abs(path: str) -> str:
"""What the toolchain makes of a tool path (a drive is added on Windows)."""
return str(Path(path).absolute())
def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CXX", "/opt/clang++")
with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get):
assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),)
def test_find_command_keeps_the_override_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``CC="gcc -m32"`` is a command, as make and CMake read it."""
monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'")
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
"-m32",
"--sysroot=/opt/my sdk",
)
@pytest.mark.parametrize(
("override", "expected_args"),
[("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))],
)
def test_find_command_drops_a_ccache_prefix(
monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...]
) -> None:
"""The build adds ccache itself; the compiler is the word after it."""
monkeypatch.setenv("CC", override)
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
*expected_args,
)
def test_find_command_accepts_a_compiler_named_ccache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Alone, the word is the program: ccache's compiler links work that way."""
monkeypatch.setenv("CC", "ccache")
with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get):
assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),)
def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""A broken override fails by name instead of silently using another compiler."""
monkeypatch.setenv("CXX", "nope++")
with (
patch("shutil.which", return_value=None),
pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"),
):
toolchain.find_command("CXX", ("g++",))
def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CC", " ")
with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (
_abs("/usr/bin/clang"),
)
def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),)
def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
with (
patch("shutil.which", return_value=None),
pytest.raises(
EsphomeError,
match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC",
),
):
toolchain.find_command("CC", ("gcc", "clang"))
def test_find_command_makes_a_relative_hit_absolute(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CC", "./toolchain/gcc")
with patch("shutil.which", return_value="./toolchain/gcc"):
(found,) = toolchain.find_command("CC", ("gcc",))
assert Path(found) == Path("toolchain/gcc").absolute()
def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AR", "ar --plugin x")
with (
patch("shutil.which", return_value="/usr/bin/ar"),
pytest.raises(EsphomeError, match="AR must name a program without arguments"),
):
toolchain.find_tool("AR", ("ar",))
def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("CC", "CXX"):
monkeypatch.delenv(var, raising=False)
table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_compilers() == toolchain.HostCompilers(
cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),)
)
def test_build_paths(tmp_path: Path) -> None:
"""The PlatformIO layout is kept: CORE.firmware_bin resolves the same file."""
assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev"
assert toolchain.get_elf_path() == CORE.firmware_bin
def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("OBJDUMP", "READELF"):
monkeypatch.delenv(var, raising=False)
table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump"))
assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf"))
def test_get_build_env_merges_without_leaking(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache"))
monkeypatch.delenv("CCACHE_DIR", raising=False)
monkeypatch.setenv("KEEP_ME", "1")
env = toolchain.get_build_env("/usr/bin/ccache")
assert env["KEEP_ME"] == "1"
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
assert "CCACHE_DIR" not in os.environ
def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CCACHE_DIR", raising=False)
assert "CCACHE_DIR" not in toolchain.get_build_env(None)
@pytest.fixture
def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]:
"""Stub everything run_compile resolves; the build dir holds a manifest."""
build_dir = tmp_path / ".pioenvs" / "dev"
build_dir.mkdir(parents=True)
(build_dir / "build.ninja").write_text("rule x\n")
compilers = toolchain.HostCompilers(("gcc",), ("g++",))
with (
patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")),
patch.object(toolchain, "find_compilers", return_value=compilers),
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
patch("esphome.build_gen.host.write_project", return_value=True) as project,
patch.object(toolchain, "refresh_compile_commands") as refresh,
patch.object(
toolchain, "_load_idedata", return_value={"cc_path": "gcc"}
) as ide,
patch("subprocess.run") as run,
):
yield SimpleNamespace(
build_dir=build_dir,
compilers=compilers,
write_project=project,
refresh=refresh,
idedata=ide,
run=run,
)
def _completed(rc: int = 0) -> SimpleNamespace:
return SimpleNamespace(returncode=rc, stdout="", stderr="")
def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None:
elf = compile_env.build_dir / PROGRAM_NAME
def build(cmd: list[str], **kwargs: object) -> SimpleNamespace:
elf.write_text("")
return _completed()
compile_env.run.side_effect = build
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
assert toolchain.run_compile(config, verbose=True) == 0
compile_env.write_project.assert_called_once_with(compile_env.compilers, None)
compile_env.refresh.assert_called_once()
assert compile_env.refresh.call_args.args[3] is True
compile_env.run.assert_called_once()
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
"-v",
"-j",
"4",
PROGRAM_NAME,
]
assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir
compile_env.idedata.assert_called_once_with(None)
def test_run_compile_defaults(compile_env: SimpleNamespace) -> None:
"""No verbosity and no process limit: just the ninja target."""
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
PROGRAM_NAME,
]
# The build inherits stdio and never captures: progress must stream
kwargs = compile_env.run.call_args.kwargs
assert kwargs["check"] is False
assert kwargs["close_fds"] is False
assert "capture_output" not in kwargs
def test_run_compile_warns_about_dropped_platformio_options(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"}
with caplog.at_level(logging.WARNING):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "platformio_options->board_build.f_cpu is ignored" in caplog.text
assert "native 'host' toolchain" in caplog.text
assert "lib_ignore" not in caplog.text
def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
with patch.object(
toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache"
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
compile_env.write_project.assert_called_once_with(
compile_env.compilers, "/usr/bin/ccache"
)
compile_env.idedata.assert_called_once_with("/usr/bin/ccache")
def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None:
compile_env.run.return_value = _completed(rc=3)
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3
compile_env.idedata.assert_not_called()
def test_run_compile_missing_program_fails(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
"""A green ninja run that produced no program fails by name."""
compile_env.run.return_value = _completed()
with caplog.at_level(logging.ERROR):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1
assert "Build produced no" in caplog.text
compile_env.idedata.assert_not_called()
@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None])
def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None:
with (
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache),
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1}
) as load,
):
assert toolchain.get_idedata() == {"x": 1}
load.assert_called_once_with(
tmp_path / ".pioenvs" / "dev" / "compile_commands.json",
tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME,
CORE.relative_internal_path("idedata", "dev.json"),
launcher=ccache,
)
@pytest.mark.parametrize("platform", ["darwin", "win32"])
def test_check_analysis_supported_refuses_non_elf(platform: str) -> None:
with (
patch.object(toolchain.sys, "platform", platform),
pytest.raises(EsphomeError, match=f"the host build on {platform}"),
):
toolchain.check_analysis_supported()
def test_check_analysis_supported_accepts_linux() -> None:
with patch.object(toolchain.sys, "platform", "linux"):
toolchain.check_analysis_supported()