mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 19:44:08 +00:00
330 lines
12 KiB
Python
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()
|