Files
esphome/tests/unit_tests/test_platformio_pch_script.py
T

555 lines
21 KiB
Python

"""Tests for esphome/platformio/pch.py.script against a fake SCons env."""
from __future__ import annotations
from collections.abc import Callable
import os
from pathlib import Path
import stat
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.build_helpers import pch
from esphome.platformio import toolchain
pytestmark = pytest.mark.skipif(
os.name == "nt", reason="the fake compiler is a POSIX shell script"
)
_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script"
class _FakePlatform:
name = "fake"
packages = {"framework-x": {}, "toolchain-y": {}}
def get_package_version(self, name: str) -> str | None:
# None for an optional package that is not installed
return None if name == "toolchain-y" else "1.2.3"
class _FakeSConsEnv(dict):
"""Just enough of a SCons construction environment for pch.py."""
def __init__(
self,
proj_dir: Path,
src_dir: Path,
cxx: str,
flags: list[str],
platform_cls: type[_FakePlatform] = _FakePlatform,
):
super().__init__(ENV={})
self._subst = {
"$PROJECT_DIR": str(proj_dir),
"$PROJECT_SRC_DIR": str(src_dir),
"$CXX": cxx,
}
self._flags = flags
self._platform_cls = platform_cls
self.prepended: list[str] = []
def subst(self, expr: str) -> str: # noqa: N802
return self._subst[expr]
def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802
return [self._flags]
def PioPlatform(self) -> _FakePlatform: # noqa: N802
return self._platform_cls()
def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803
self.prepended = CXXFLAGS
def Flatten(self, nodes: list) -> list: # noqa: N802
return nodes
def GetProjectOption(self, name: str, default: list) -> list: # noqa: N802
return self.get(f"option:{name}", default)
def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path:
"""A compiler stand-in that records its argv and writes the -o target."""
cxx = tmp_path / "fake-gxx"
# As GCC: the program's path when it exists next to the driver, else its
# name; the version from FAKE_GCC_VERSION
body = (
'case "$1" in -print-prog-name=*) n=${1#*=};'
' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";'
' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;'
' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;;'
' --version) echo "${FAKE_GCC_BANNER:-fake-g++ (test) ${FAKE_GCC_VERSION:-10.3.1}}";'
" exit 0;; esac\n"
'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n'
)
if fail:
body += "echo boom >&2\nexit 1\n"
else:
body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n'
body += '[ -n "$out" ] && echo gch > "$out"\n'
cxx.write_text("#!/bin/sh\n" + body)
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
return cxx
def _run_script(
tmp_path: Path,
flags: list[str] | None = None,
fail: bool = False,
env_vars: dict[str, str] | None = None,
name: str = "dev",
platform_cls: type[_FakePlatform] = _FakePlatform,
build_files: Callable[[tuple], list] | None = None,
cxx: Path | None = None,
) -> _FakeSConsEnv:
proj = tmp_path / name
src = proj / "src"
(src / "esphome" / "core").mkdir(parents=True, exist_ok=True)
(src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n")
(src / "esphome" / "core" / "pch_prefix.h").write_text(
'#include "esphome/core/defines.h"\n'
)
if cxx is None:
cxx = _fake_cxx(tmp_path, fail=fail)
args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls)
# Distinct objects: the -include flags must land on projenv only
global_env = _FakeSConsEnv(*args)
projenv = _FakeSConsEnv(*args)
projenv.global_env = global_env
if build_files is not None:
global_env["PIOBUILDFILES"] = build_files(args)
source = _SCRIPT.read_text()
with patch.dict(os.environ, env_vars or {}, clear=True):
exec( # noqa: S102
compile(source, "pch.py", "exec"),
{
"Import": lambda *_names: None,
"env": global_env,
"projenv": projenv,
"COMMAND_LINE_TARGETS": [],
},
)
return projenv
def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None:
scons_env = _run_script(tmp_path)
proj = tmp_path / "dev"
assert (proj / "esphome_pch_src.h").read_text() == pch.pch_header_text(
pch.PCH_DEFAULT_HEADERS
)
assert (proj / "esphome_pch.h").read_text() == pch.PCH_GUARD_TEXT
assert (proj / "esphome_pch.h.gch").is_file()
assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64
assert scons_env.prepended == pch.pch_consumer_flags()
# The -include flags are scoped to projenv (src compiles)
assert scons_env.global_env.prepended == []
class _LibreTinyPlatform(_FakePlatform):
name = "libretiny"
def _run_on_host(
tmp_path: Path,
host: str,
machine: str,
platform_cls: type[_FakePlatform],
cxx: Path | None = None,
gcc_version: str = "10.3.1",
) -> _FakeSConsEnv:
with (
patch.object(sys, "platform", host),
patch("platform.machine", return_value=machine),
):
return _run_script(
tmp_path,
platform_cls=platform_cls,
cxx=cxx,
env_vars={"FAKE_GCC_VERSION": gcc_version},
)
def _fake_toolchain(tmp_path: Path) -> tuple[Path, Path]:
"""A driver in bin/ and a cc1plus that records its argv in libexec/."""
toolchain = tmp_path / "toolchain"
(toolchain / "bin").mkdir(parents=True)
cxx = _fake_cxx(toolchain / "bin")
real = toolchain / "libexec" / "gcc" / "arm-none-eabi" / "10.3.1" / "cc1plus"
real.parent.mkdir(parents=True)
real.write_text('#!/bin/sh\nprintf \'%s\\n\' "$@" > "$0.argv"\n')
real.chmod(0o755)
return cxx, real
def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None:
"""The LibreTiny .gch compile and the consumers get a -B directory
holding a cc1plus that starts the real one."""
cxx, real = _fake_toolchain(tmp_path)
scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx)
wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus"
assert wrapper.stat().st_mode & stat.S_IXUSR
assert repr(str(real)) in wrapper.read_text(encoding="utf-8")
argv = Path(f"{cxx}.argv").read_text(encoding="utf-8").split("\n")
assert f"-B{pch.PCH_CC1_DIR}/" in argv
assert scons_env.prepended == [f"-B{pch.PCH_CC1_DIR}/", *pch.pch_consumer_flags()]
@pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only")
def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None:
cxx, real = _fake_toolchain(tmp_path)
_run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx)
wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus"
result = subprocess.run(
[str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False
)
assert result.returncode == 0, result.stderr
assert Path(f"{real}.argv").read_text(encoding="utf-8") == "-quiet\nx.cpp\n"
def test_pch_script_gcc10_without_cc1plus_builds_plainly(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A driver without a cc1plus of its own gets no wrapper and no header."""
scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform)
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert scons_env.prepended == []
assert "compiling without it" in capsys.readouterr().out
@pytest.mark.parametrize(
("host", "machine"), [("darwin", "x86_64"), ("win32", "AMD64")]
)
def test_pch_script_gcc10_skipped_elsewhere(
tmp_path: Path, host: str, machine: str, capsys: pytest.CaptureFixture[str]
) -> None:
"""Where the GCC 10 .gch cannot load, the build runs without it."""
scons_env = _run_on_host(tmp_path, host, machine, _LibreTinyPlatform)
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert scons_env.prepended == []
assert "compiling without it" in capsys.readouterr().out
@pytest.mark.parametrize(
("host", "machine", "platform_cls", "gcc_version"),
[
("darwin", "arm64", _FakePlatform, "10.3.1"),
("linux", "aarch64", _LibreTinyPlatform, "10.3.1"),
# From GCC 12 the .gch loads at any address, so the rule retires itself
("darwin", "arm64", _LibreTinyPlatform, "12.2.0"),
# Windows needs the fix for GCC bug 14940 as well
("win32", "AMD64", _LibreTinyPlatform, "14.4.0"),
],
)
def test_pch_script_no_wrapper_where_the_gch_loads(
tmp_path: Path,
host: str,
machine: str,
platform_cls: type[_FakePlatform],
gcc_version: str,
) -> None:
scons_env = _run_on_host(
tmp_path, host, machine, platform_cls, gcc_version=gcc_version
)
assert not (tmp_path / "dev" / pch.PCH_CC1_DIR).exists()
assert scons_env.prepended == pch.pch_consumer_flags()
def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None:
"""The script cannot import esphome, so its copies are pinned."""
namespace: dict[str, object] = {
"Import": lambda *_names: None,
"env": _FakeSConsEnv(tmp_path, tmp_path, "g++", []),
"projenv": None,
"COMMAND_LINE_TARGETS": ["nobuild"],
}
exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102
assert namespace["_HEADER_NAME"] == pch.PCH_HEADER_NAME
assert namespace["_SOURCE_NAME"] == pch.PCH_SOURCE_NAME
assert namespace["_DEFAULT_HEADERS"] == pch.PCH_DEFAULT_HEADERS
assert namespace["_CONSUMER_FLAGS"] == pch.pch_consumer_flags()
assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT
assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern
assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR
assert namespace["_WINDOWS_GCC_FIXED"] == pch.PCH_WINDOWS_GCC_FIXED
assert namespace["_WINDOWS_GCC_FIXED_DEFAULT"] == pch.PCH_WINDOWS_GCC_FIXED_DEFAULT
@pytest.mark.parametrize(
("version", "env_vars", "on"),
[
("14.2.0", {}, False),
("14.4.0", {}, True),
("14.2.0", {"ESPHOME_PCH_ENABLE": "1"}, True),
],
)
def test_pch_script_asks_the_compiler_on_windows(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
version: str,
env_vars: dict[str, str],
on: bool,
) -> None:
"""The Python rule again, plus the normalized knob."""
monkeypatch.setattr(sys, "platform", "win32")
scons_env = _run_script(
tmp_path, env_vars={"FAKE_GCC_VERSION": version, **env_vars}
)
assert (scons_env.prepended == pch.pch_consumer_flags()) is on
assert (tmp_path / "dev" / "esphome_pch.h.gch").is_file() is on
out = capsys.readouterr().out
assert ("cannot load a precompiled header on Windows" in out) is not on
def test_pch_script_spares_another_compiler_the_gcc_rule(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys, "platform", "win32")
scons_env = _run_script(
tmp_path, env_vars={"FAKE_GCC_BANNER": "clang version 15.0.0"}
)
assert scons_env.prepended == pch.pch_consumer_flags()
def test_pch_script_leaves_the_header_off_when_the_compiler_cannot_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "platform", "win32")
scons_env = _run_script(tmp_path, cxx=tmp_path / "missing-g++")
assert scons_env.prepended == []
assert "GCC of unknown version cannot load" in capsys.readouterr().out
def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None:
"""The pch holds only ESPHome's own headers: a failure is a defect."""
with pytest.raises(RuntimeError, match="could not compile") as raised:
_run_script(tmp_path, fail=True)
assert "boom" in str(raised.value)
assert not (tmp_path / "dev" / "esphome_pch.h.gch.sum").exists()
def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
"""One SCons element stays one compiler argv; -include pairs are
stripped from the .gch compile."""
spaced = tmp_path / "My Configs"
spaced.mkdir()
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "dev" / "src" / "other.h").write_text("")
flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"]
_run_script(tmp_path, flags=flags)
calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n")
gch_call = next(c for c in calls if "c++-header" in c).splitlines()
assert '-DUSB_PRODUCT="Pico 2W"' in gch_call
assert str(spaced) in gch_call
assert "-include" not in gch_call
# The stripped src-resolvable -include is folded into the prefix header
pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text()
assert pch.splitlines()[0] == '#include "other.h"'
def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None:
"""-includefoo.h folds like the separated form, matching the native path."""
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "dev" / "src" / "other.h").write_text("")
_run_script(tmp_path, flags=["-DX=1", "-includeother.h"])
pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text()
assert pch.splitlines()[0] == '#include "other.h"'
def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None:
"""Regression: identical configs in different dirs share cache keys."""
sums = []
for name in ("dev_a", "dev_b"):
proj = tmp_path / name
_run_script(
tmp_path,
flags=["-DX=1", "-I", str(proj / "include")],
env_vars={"CCACHE_BASEDIR": str(proj)},
name=name,
)
sums.append((proj / "esphome_pch.h.gch.sum").read_text())
(tmp_path / "fake-gxx").unlink()
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
assert sums[0] == sums[1]
def test_pch_script_uses_the_envs_of_existing_src_objects(tmp_path: Path) -> None:
"""The ESP-IDF builder creates the src objects from its own environments
before this script runs; flags on projenv would never reach them."""
made: list[_FakeSConsEnv] = []
def build_files(args: tuple) -> list:
src = args[1]
made.extend(_FakeSConsEnv(*args) for _ in range(3))
return [
SimpleNamespace(env=made[0], sources=[src / "main.cpp"]),
SimpleNamespace(env=made[0], sources=[src / "esphome" / "a.cpp"]),
# C and framework objects keep their environments untouched
SimpleNamespace(env=made[1], sources=[src / "esphome" / "b.c"]),
SimpleNamespace(env=made[2], sources=[src.parent / "lib" / "c.cpp"]),
]
projenv = _run_script(tmp_path, build_files=build_files)
assert made[0].prepended == pch.pch_consumer_flags()
assert made[1].prepended == made[2].prepended == projenv.prepended == []
def test_copy_pch_script(tmp_path: Path) -> None:
from esphome.core import CORE
CORE.build_path = tmp_path
toolchain.copy_pch_script()
assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text()
def _run_without_projenv(tmp_path: Path, targets: list[str]) -> Path:
proj = tmp_path / "dev"
(proj / "src").mkdir(parents=True)
def strict_import(*names: str) -> None:
if "projenv" in names:
raise RuntimeError("Import of non-existent variable 'projenv'")
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
exec( # noqa: S102
compile(_SCRIPT.read_text(), "pch.py", "exec"),
{"Import": strict_import, "env": env, "COMMAND_LINE_TARGETS": targets},
)
return proj
def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None:
"""-t nobuild never exports projenv; the script must not abort."""
proj = _run_without_projenv(tmp_path, ["nobuild"])
assert not (proj / "esphome_pch.h").exists()
def test_pch_script_build_without_projenv_stops(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="no project environment"):
_run_without_projenv(tmp_path, [])
def test_pch_script_stops_when_no_source_object_matches(tmp_path: Path) -> None:
"""Flags on projenv would reach no compile while the log says the
precompiled header is in use."""
def build_files(args: tuple) -> list:
lib = args[1].parent / "lib" / "c.cpp"
return [SimpleNamespace(env=_FakeSConsEnv(*args), sources=[lib])]
with pytest.raises(RuntimeError, match="no C\\+\\+ source takes"):
_run_script(tmp_path, build_files=build_files)
def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None:
""".piolibdeps and non-header files must not enter the digest (or be
read at all); package versions already cover library identity."""
proj = tmp_path / "dev"
libdeps = proj / ".piolibdeps" / "lib" / "src"
libdeps.mkdir(parents=True)
(libdeps / "lib.h").write_text("#define A 1\n")
override = proj / "lwip_override"
override.mkdir(parents=True)
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
(override / "notes.txt").write_text("v1\n")
flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
(libdeps / "lib.h").write_text("#define A 2\n")
(override / "notes.txt").write_text("v2\n")
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() == first
def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
"""Generated headers in project-local -I dirs (e.g. rp2's lwip_override)
must invalidate the checksum when they change."""
proj = tmp_path / "dev"
override = proj / "lwip_override"
override.mkdir(parents=True)
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
flags = ["-DX=1", "-I", str(override)]
_run_script(tmp_path, flags=flags)
first = (proj / "esphome_pch.h.gch.sum").read_text()
(override / "lwipopts.h").write_text("#define TCP_MSS 536\n")
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
def test_pch_script_folds_force_includes_found_on_the_include_path(
tmp_path: Path,
) -> None:
"""A framework force-include (Arduino.h) is read before the core
headers, as it was without the precompiled header."""
_run_script(tmp_path, flags=["-DX=1", "-include", "Arduino.h"])
source = (tmp_path / "dev" / "esphome_pch_src.h").read_text()
assert source.splitlines() == [
'#include "Arduino.h"',
'#include "esphome/core/pch_prefix.h"',
]
def test_pch_script_skipped_for_an_absolute_force_include(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
outside = tmp_path / "outside.h"
outside.write_text("")
scons_env = _run_script(tmp_path, flags=["-DX=1", "-include", str(outside)])
assert scons_env.prepended == []
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
assert "prevents the precompiled header" in capsys.readouterr().out
def _sum_after(tmp_path: Path, flags: list[str]) -> str:
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
_run_script(tmp_path, flags=flags)
return (tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text()
@pytest.mark.parametrize(
"option", [["-Iinclude"], ["-isystem", "include"], ["-iquoteinclude"]]
)
def test_pch_script_hashes_relative_include_dirs(
tmp_path: Path, option: list[str]
) -> None:
"""Compiles run in the project root, so -Iinclude is a project dir."""
include = tmp_path / "dev" / "include"
include.mkdir(parents=True)
(include / "user.h").write_text("#define A 1\n")
flags = ["-DX=1", *option, "-I."]
first = _sum_after(tmp_path, flags)
(include / "user.h").write_text("#define A 2\n")
assert _sum_after(tmp_path, flags) != first
def test_pch_script_hashes_the_sdkconfig(tmp_path: Path) -> None:
"""sdkconfig.h is build output; the configuration it comes from is not."""
proj = tmp_path / "dev"
proj.mkdir()
(proj / "sdkconfig.dev").write_text("CONFIG_X=y\n")
first = _sum_after(tmp_path, ["-DX=1"])
(proj / "sdkconfig.dev").write_text("CONFIG_X=n\n")
assert _sum_after(tmp_path, ["-DX=1"]) != first
def test_pch_script_hashes_the_library_versions(tmp_path: Path) -> None:
sums = []
for version in ("7.4.1", "7.4.2"):
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
with patch.object(
_FakeSConsEnv,
"GetProjectOption",
lambda self, name, default, version=version: [f"ArduinoJson@{version}"],
):
_run_script(tmp_path)
sums.append((tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text())
assert sums[0] != sums[1]