mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 19:06:37 +00:00
[core] Extract the shared idedata and size-summary helpers into build_helpers (#18661)
This commit is contained in:
@@ -1120,7 +1120,13 @@ def test_should_run_esp32_platformio_with_branch() -> None:
|
||||
(["esphome/espidf/runner.py"], True),
|
||||
(["esphome/espidf/framework.py"], True),
|
||||
(["esphome/build_gen/espidf.py"], True),
|
||||
# PlatformIO build gen and esp32 component are NOT IDF-infra triggers
|
||||
# Shared native-build modules the IDF build imports -> trigger
|
||||
(["esphome/build_helpers/idedata.py"], True),
|
||||
(["esphome/platformio/library.py"], True),
|
||||
(["esphome/framework_helpers.py"], True),
|
||||
# PlatformIO build gen, its toolchain, and the esp32 component are
|
||||
# NOT IDF-infra triggers
|
||||
(["esphome/platformio/toolchain.py"], False),
|
||||
(["esphome/build_gen/platformio.py"], False),
|
||||
(["esphome/components/esp32/__init__.py"], False),
|
||||
(["README.md"], False),
|
||||
@@ -1132,6 +1138,16 @@ def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None
|
||||
assert determine_jobs._esp_idf_infra_changed(changed_files) is expected
|
||||
|
||||
|
||||
def test_esp_idf_infra_trigger_paths_exist() -> None:
|
||||
"""A renamed or moved trigger module must fail here, not silently stop
|
||||
forcing the esp32 IDF compile."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
for file in determine_jobs.ESP_IDF_INFRA_TRIGGER_FILES:
|
||||
assert (repo_root / file).is_file(), f"trigger file {file} moved or renamed"
|
||||
for prefix in determine_jobs.ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES:
|
||||
assert (repo_root / prefix).is_dir(), f"trigger dir {prefix} moved or renamed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
@@ -9,7 +9,7 @@ from esphome.analyze_memory.toolchain import (
|
||||
find_idedata_path,
|
||||
idedata_candidates,
|
||||
)
|
||||
from esphome.espidf.idedata import _cc_path_from_cxx
|
||||
from esphome.build_helpers.idedata import _cc_path_from_cxx
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Tests for esphome.build_helpers.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import idedata
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "launcher"),
|
||||
[
|
||||
("", None),
|
||||
# A command that is only the launcher strips to nothing
|
||||
("/usr/bin/ccache", "/usr/bin/ccache"),
|
||||
],
|
||||
)
|
||||
def test_parse_entry_empty_command_raises(command: str, launcher: str | None) -> None:
|
||||
"""A blank (or launcher-only) command fails with a named ValueError,
|
||||
not an IndexError."""
|
||||
entry = {"directory": "/b", "file": "/b/src/x.cpp", "command": command}
|
||||
with pytest.raises(ValueError, match="empty compile command"):
|
||||
idedata.parse_entry(entry, launcher)
|
||||
|
||||
|
||||
def test_idedata_from_build_empty_includes_raises(tmp_path: Path) -> None:
|
||||
"""A compile DB with no ESPHome TU is never usable idedata and must
|
||||
not be cached (call sites downgrade the raise to a build warning)."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/other/lib.cpp",
|
||||
"/tools/g++ -c other/lib.cpp -o lib.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
pytest.raises(EsphomeError, match="No ESPHome translation unit found"),
|
||||
):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_rsp_commands_never_dedupe(tmp_path: Path) -> None:
|
||||
"""Per-object response files strip to one shape while holding different
|
||||
include sets; @-commands must tokenize per TU."""
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.cpp.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_idedata_from_build_dedupes_identical_command_shapes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Translation units sharing one ninja rule (same command modulo
|
||||
file/output) carry
|
||||
identical includes, so only one per shape is tokenized; a differing
|
||||
shape still contributes its includes."""
|
||||
|
||||
def _tu(name: str, inc: str) -> dict:
|
||||
# ninja's compdb embeds the file and output strings verbatim
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
return _entry(
|
||||
f"{ABS}build", file, f"/tools/g++ -I{ABS}inc/{inc} -c {file} -o {name}.o"
|
||||
) | {"output": f"{name}.o"}
|
||||
|
||||
entries = [_tu(name, "shared") for name in ("application", "component", "helpers")]
|
||||
entries.append(_tu("extra", "extra"))
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(idedata, "parse_entry", wraps=idedata.parse_entry) as spy,
|
||||
):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
includes = set(data["includes"]["build"])
|
||||
assert f"{ABS}inc/shared".replace("\\", "/") in {
|
||||
i.replace("\\", "/") for i in includes
|
||||
}
|
||||
assert any("inc/extra" in i for i in includes)
|
||||
# Representative + one distinct shape; the two same-shape duplicates
|
||||
# are never tokenized
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
|
||||
|
||||
def test_parse_entry_strips_launcher_prefix() -> None:
|
||||
"""A launcher-wrapped compile names the compiler second; the exact
|
||||
configured launcher is stripped, not anything ccache-shaped."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
|
||||
"-c app.cpp -o app.cpp.o",
|
||||
)
|
||||
cxx_path, defines, _, _ = idedata.parse_entry(
|
||||
entry, launcher="/opt/homebrew/bin/ccache"
|
||||
)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert defines == ["USE_ESP8266"]
|
||||
|
||||
|
||||
def test_parse_entry_recovers_from_unconfigured_launcher(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A stale compile DB built with a launcher this run no longer configures
|
||||
still yields the real compiler (the next token), not the launcher."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o",
|
||||
)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
cxx_path, _, _, _ = idedata.parse_entry(entry)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert "Stripping unconfigured launcher" in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_rejects_launcher_without_program() -> None:
|
||||
"""A launcher followed only by flags is rejected in the parser itself,
|
||||
so no caller can record ccache as the compiler."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache -c a.cpp -o a.o",
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.parse_entry(entry)
|
||||
|
||||
|
||||
def _write_compile_commands(tmp_path: Path) -> Path:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
return compile_commands
|
||||
|
||||
|
||||
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache" / "test.json"
|
||||
with patch.object(
|
||||
idedata, "get_toolchain_includes", return_value=["/toolchain/include"]
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["cc_path"] == "/tools/gcc"
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
assert json.loads(cache.read_text()) == data
|
||||
|
||||
# A fresh cache is served without re-parsing the compile DB
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
assert (
|
||||
idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
== data
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ("not json", json.dumps({"no_cc_path": True})):
|
||||
cache.write_text(bad)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert "cc_path" in data
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_when_compile_db_newer(tmp_path: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
cache.write_text(json.dumps({"cc_path": "stale"}))
|
||||
os.utime(compile_commands, (cache.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cc_path"] != "stale"
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not an object is regenerated, never handed out.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring.
|
||||
"""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ('"cc_path is a string"', "[]", "42"):
|
||||
cache.write_text(bad)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert isinstance(data, dict)
|
||||
assert "cc_path" in data
|
||||
|
||||
|
||||
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
||||
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
|
||||
assert not idedata._is_launcher(token)
|
||||
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
|
||||
assert idedata._is_launcher(token)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_corrupted_cache_is_logged(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A truncated cache is diagnosable, not a silent slow-build cause."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text('{"cc_path": trunc')
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_discards_unreadable_cache_file(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An OSError on the cache read (permissions, I/O) regenerates like a
|
||||
parse failure instead of aborting the consumer."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text("{}")
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
real_read_text = Path.read_text
|
||||
|
||||
def fail_cache_read(self: Path, *args: object, **kwargs: object) -> str:
|
||||
# chmod(0) cannot revoke read access on Windows, so fault the read
|
||||
# itself for a platform-independent OSError
|
||||
if self == cache:
|
||||
raise OSError("permission denied")
|
||||
return real_read_text(self, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(Path, "read_text", fail_cache_read),
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
|
||||
"""A compile DB naming a launcher as the compiler is rejected, never cached."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
cache = tmp_path / "c.json"
|
||||
# No probe patch needed: the launcher is rejected before the probe runs
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.load_or_build_idedata(compile_commands, tmp_path / "f.elf", cache)
|
||||
assert not cache.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached",
|
||||
(
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}},
|
||||
),
|
||||
ids=("launcher-cxx", "no-includes", "no-build-list"),
|
||||
)
|
||||
def test_load_or_build_idedata_regenerates_invalid_cache(
|
||||
tmp_path: Path, cached: dict
|
||||
) -> None:
|
||||
"""A cache written by an older version fails validation and regenerates."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(json.dumps(cached))
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "includes" in data
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None:
|
||||
"""A served cache carries the current ELF path, not the one it was written with."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": [], "toolchain": []},
|
||||
"prog_path": "/old/location/firmware.elf",
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
|
||||
|
||||
def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not a list raises by name, inside the best-effort tuple."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
for bad in ("{}", "null", '"text"', '["a", "b"]', "[1, 2]"):
|
||||
compile_commands.write_text(bad)
|
||||
with pytest.raises(EsphomeError, match="not a compile-command list"):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_same_file_rsp_commands_never_dedupe(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two objects built from one source with different .rsp files keep both
|
||||
include sets; the rsp sentinel keys on the output, not the source."""
|
||||
file = f"{ABS}build/src/esphome/core/shared.cpp"
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
|
||||
"""A valid cache newer than the compile DB is served without re-parsing."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": ["/inc"], "toolchain": []},
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
assert data["cached"] is True
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for the shared PlatformIO-format size bar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers.size_summary import format_bar, print_size_line
|
||||
|
||||
|
||||
def test_format_bar_zero_total() -> None:
|
||||
"""A zero total must not divide by zero."""
|
||||
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
|
||||
|
||||
|
||||
def test_print_size_line_label_padding(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The label column is exactly what ci_memory_impact_extract.py greps."""
|
||||
print_size_line("RAM", 47932, 180736)
|
||||
print_size_line("Flash", 888511, 1835008)
|
||||
out = capsys.readouterr().out.splitlines()
|
||||
assert out[0].startswith("RAM: [")
|
||||
assert out[1].startswith("Flash: [")
|
||||
assert "26.5% (used 47932 bytes from 180736 bytes)" in out[0]
|
||||
@@ -6,9 +6,9 @@ from unittest.mock import patch
|
||||
|
||||
from hypothesis import given
|
||||
import pytest
|
||||
from strategies import mac_addr_strings
|
||||
|
||||
from esphome import const, core
|
||||
from tests.unit_tests.strategies import mac_addr_strings
|
||||
|
||||
|
||||
class TestHexInt:
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import clang_tidy
|
||||
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -64,3 +67,35 @@ def test_setup_core_sets_arduino_env(
|
||||
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
|
||||
|
||||
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project(tmp_path) -> None:
|
||||
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": str(tmp_path / "main" / "tidy.cpp"),
|
||||
"command": "/tc/xtensa-esp32-elf-g++ -DUSE_ESP32 "
|
||||
f"-I{tmp_path}/inc -c main/tidy.cpp -o tidy.o",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"esphome.espidf.clang_tidy.get_toolchain_includes", return_value=["/tc/inc"]
|
||||
):
|
||||
data = clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
assert data["cxx_path"] == "/tc/xtensa-esp32-elf-g++"
|
||||
assert data["defines"] == ["USE_ESP32"]
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc"]
|
||||
assert any(inc.endswith("/inc") for inc in data["includes"]["build"])
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps([]))
|
||||
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
|
||||
clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import idedata
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# _parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
@@ -140,7 +140,7 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
@@ -151,114 +151,6 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
|
||||
|
||||
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
"""A cache at least as new as the compile DB is reused without regenerating."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_not_called()
|
||||
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
|
||||
"""A cache predating cc_path is rebuilt even though it is newer.
|
||||
|
||||
Such a cache stays newer than the compile DB forever, so consumers that
|
||||
derive the binutils paths from cc_path would keep failing on it.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result["cc_path"] == "gcc"
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "stale"}')
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache_mtime = cache.stat().st_mtime
|
||||
os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "fresh"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
|
||||
def test_get_idedata_regenerates_on_non_dict_cache(
|
||||
setup_core: Path, cached: str
|
||||
) -> None:
|
||||
"""A newer cache holding valid JSON that is not an object is regenerated.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring and be
|
||||
handed to consumers expecting a dict.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(cached)
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
"""An unparseable (but newer) cache falls back to regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text("{not json")
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "regen"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
"""The idedata exposes prog_path (the ELF) so consumers like build-action
|
||||
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
|
||||
@@ -267,7 +159,7 @@ def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
):
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
@@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import CaptureFixture
|
||||
import serial
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
from esphome import __main__ as main, yaml_util
|
||||
@@ -26,6 +27,7 @@ from esphome.__main__ import (
|
||||
_make_crystal_freq_callback,
|
||||
_redact_with_legacy_fallback,
|
||||
_resolve_network_devices,
|
||||
_should_subscribe_states,
|
||||
_split_network_devices,
|
||||
_unresolved_default_error,
|
||||
_validate_bootloader_binary,
|
||||
@@ -69,19 +71,21 @@ from esphome.__main__ import (
|
||||
)
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
|
||||
from esphome.components import esp32, esp8266
|
||||
from esphome.components import esp32, esp8266, mqtt
|
||||
from esphome.components.esp32 import (
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
get_esp32_variant,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
@@ -103,6 +107,7 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
@@ -567,8 +572,6 @@ def test_command_config__no_defaults_dumps_user_snapshot(
|
||||
) -> None:
|
||||
"""``--no-defaults`` dumps ``config.user_config`` instead of the
|
||||
validated config, so schema defaults don't leak into the output."""
|
||||
from esphome.config import Config
|
||||
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = True
|
||||
@@ -621,8 +624,6 @@ def test_command_config__no_defaults_skips_strip_default_ids(
|
||||
) -> None:
|
||||
"""When ``--no-defaults`` is set, ``strip_default_ids`` isn't run --
|
||||
the user snapshot is already free of schema-injected IDs."""
|
||||
from esphome.config import Config
|
||||
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = True
|
||||
@@ -3440,9 +3441,6 @@ def test_get_port_type() -> None:
|
||||
|
||||
def test_mqtt_reexports_discover_ip() -> None:
|
||||
"""The old import path must keep working for external code."""
|
||||
from esphome.components import mqtt
|
||||
from esphome.const import CONF_DISCOVER_IP
|
||||
|
||||
assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP
|
||||
|
||||
|
||||
@@ -5909,8 +5907,6 @@ class MockSerial:
|
||||
chunk = self.chunks[self.chunk_index]
|
||||
if chunk is MOCK_SERIAL_END:
|
||||
# Sentinel means we're done - simulate port closed
|
||||
import serial
|
||||
|
||||
raise serial.SerialException("Port closed")
|
||||
# Respect the requested size and keep any remaining bytes
|
||||
if size <= 0:
|
||||
@@ -5924,8 +5920,6 @@ class MockSerial:
|
||||
# Entire chunk consumed; advance to the next one
|
||||
self.chunk_index += 1
|
||||
return data # type: ignore[return-value]
|
||||
import serial
|
||||
|
||||
raise serial.SerialException("Port closed")
|
||||
|
||||
|
||||
@@ -6784,8 +6778,6 @@ def test_parse_args_argcomplete_only_runs_when_completing() -> None:
|
||||
|
||||
def test_should_subscribe_states_default() -> None:
|
||||
"""Test that states are shown by default when nothing is set."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ESPHOME_LOG_STATES", None)
|
||||
@@ -6794,8 +6786,6 @@ def test_should_subscribe_states_default() -> None:
|
||||
|
||||
def test_should_subscribe_states_env_suppresses() -> None:
|
||||
"""Test that ESPHOME_LOG_STATES=false suppresses states by default."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
|
||||
assert _should_subscribe_states(args) is False
|
||||
@@ -6803,8 +6793,6 @@ def test_should_subscribe_states_env_suppresses() -> None:
|
||||
|
||||
def test_should_subscribe_states_env_enables() -> None:
|
||||
"""Test that ESPHOME_LOG_STATES=true enables states by default."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
|
||||
assert _should_subscribe_states(args) is True
|
||||
@@ -6812,8 +6800,6 @@ def test_should_subscribe_states_env_enables() -> None:
|
||||
|
||||
def test_should_subscribe_states_flag_overrides_env() -> None:
|
||||
"""Test that --states overrides ESPHOME_LOG_STATES=false."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "--states", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
|
||||
assert _should_subscribe_states(args) is True
|
||||
@@ -6821,8 +6807,6 @@ def test_should_subscribe_states_flag_overrides_env() -> None:
|
||||
|
||||
def test_should_subscribe_states_no_flag_overrides_env() -> None:
|
||||
"""Test that --no-states overrides ESPHOME_LOG_STATES=true."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
|
||||
assert _should_subscribe_states(args) is False
|
||||
@@ -7135,6 +7119,82 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
|
||||
assert not caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
FileNotFoundError("no such compiler"),
|
||||
RuntimeError("Could not query builtin include dirs"),
|
||||
ValueError("no C++ translation unit found"),
|
||||
KeyError("command"),
|
||||
None, # replaced with EsphomeError inside
|
||||
],
|
||||
)
|
||||
def test_compile_program_espidf_idedata_failure_does_not_fail_build(
|
||||
error: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A post-compile idedata error is a warning: the firmware already built."""
|
||||
if error is None:
|
||||
error = EsphomeError("compile database is unusable")
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", side_effect=error),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "Could not generate idedata" in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_espidf_idedata_success_is_silent(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The healthy path: idedata generated, nothing to warn about."""
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", return_value={"cc_path": "x"}),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "idedata" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_espidf_idedata_none_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A silent None from the post-compile idedata refresh is made visible."""
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", return_value=None),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "No idedata was generated" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
"""The config comment dumps with sorted keys: voluptuous fills schema
|
||||
|
||||
@@ -126,3 +126,20 @@ def test_print_summary_handles_no_memory_types(
|
||||
size_json = _write_size_json(tmp_path, {"image_size": 0})
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_print_summary_flash_line(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A partition table with an app row yields the Flash line in the exact
|
||||
padded shape script/ci_memory_impact_extract.py greps."""
|
||||
size_json = _write_size_json(tmp_path, _esp32_size_data())
|
||||
partitions = tmp_path / "partitions.csv"
|
||||
partitions.write_text(
|
||||
"# name, type, subtype, offset, size, flags\n"
|
||||
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
|
||||
)
|
||||
print_summary(size_json, partitions)
|
||||
out = capsys.readouterr().out
|
||||
assert "Flash: " in out
|
||||
assert "(used 827455 bytes from 1835008 bytes)" in out
|
||||
|
||||
Reference in New Issue
Block a user